Repository navigation
AST nodes as handles, offsets into areanas - #64698
Draft
Jake Bailey (jakebailey) wants to merge 38 commits into
Draft
Jake Bailey (jakebailey) wants to merge 38 commits into
Jake Bailey (jakebailey) wants to merge 38 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
The design, performance numbers and future work for this branch lived only in a gist, which drifts from the code as the branch changes. Keeping the doc in the tree lets it be updated in the same commits that change the layout.
Flow nodes are created per file by the binder and only reference
nodes and other flow nodes, so storing them as pointer-filled heap
objects behind a side table paid twice: a 4-byte index plus an 8-byte
table slot (with growth slack) on every node that has one, and GC
tracing of every flow node. As pointer-free records in the file's
chunks they need neither, and a read is one load instead of a trip
through the store's table. Callers still receive *FlowNode, since an
interior pointer keeps the whole store alive.
│ tip.txt │ flow.txt │
│ check-sec │ check-sec vs base │
VSCode 7.147 ± 2% 7.237 ± 1% ~ (p=0.280 n=10)
│ tip.txt │ flow.txt │
│ total-sec │ total-sec vs base │
VSCode 8.169 ± 2% 8.146 ± 2% ~ (p=0.971 n=10)
│ tip.txt │ flow.txt │
│ mem-B │ mem-B vs base │
VSCode 3.397Gi ± 0% 3.375Gi ± 0% -0.67% (p=0.000 n=10)
Node and flow refs were absolute addresses kept in uintptr fields: Go
pointers hidden from the garbage collector, kept alive by a side set.
That is not the design we want. Refs should be real 32-bit offsets into
a file's storage, with 64-bit handles only outside the ast package.
A ref now names a block of the store and a word offset in it; records
in the same block decode with one subtraction, other blocks through the
store's block table, and other stores through a deduplicated far table
of real pointers. Blocks point back at their store, so handles keep
everything alive with no hidden pointers.
This step is slower. Decoding a ref costs more than loading an address,
and a fifth of derefs cross blocks, since containers are allocated after
their members. It is a stepping stone toward one compacted block per
file with field-relative refs, as described in ARENA-AST.md.
│ old │ new │
│ check-sec │ check-sec vs base │
VSCode 7.321 ± 3% 8.069 ± 4% +10.22% (p=0.000 n=10)
│ old │ new │
│ total-sec │ total-sec vs base │
VSCode 8.303 ± 2% 9.037 ± 4% +8.83% (p=0.001 n=10)
│ old │ new │
│ mem-B │ mem-B vs base │
VSCode 3.374Gi ± 0% 3.340Gi ± 0% -1.02% (p=0.000 n=10)
Block-indexed refs had to load the record's self and do arithmetic on
it for every dereference, and their slow path was a call, so neither
the decode nor the accessors around it inlined. Hot parent walks paid
for that on every step.
A near ref is now a byte offset from the field holding it, which needs
nothing but the field's address. Refs leaving a block go through the
far table, which is now a copy-on-grow slice published atomically, so
the whole decode is call-free and inlines into every accessor,
including Parent.
Refs that leave their block still cost a store lookup, which is the
remaining gap to absolute refs:
│ tip │ new │
│ check-sec │ check-sec vs base │
VSCode 7.333 ± 3% 7.796 ± 1% +6.33% (p=0.007 n=8)
│ tip │ new │
│ total-sec │ total-sec vs base │
VSCode 8.220 ± 2% 8.730 ± 1% +6.21% (p=0.007 n=8)
│ tip │ new │
│ mem-B │ mem-B vs base │
VSCode 3.375Gi ± 0% 3.393Gi ± 0% +0.55% (p=0.000 n=8)
Block arenas with field-relative refs needed reflect.StructOf blocks, a far table, and unsafe address arithmetic on every ref decode, and still cost about 6% of check time. Layouts like that are fragile and hard to reason about. Following the index-based approach used by Zig's AST, a node is now its store plus a 32-bit index into a single word array, and refs are plain indices. Nodes from other stores go through a small per-store reused-node table. This removes blocks, the far table and all ref pointer arithmetic, and is slightly faster than the previous scheme. Flow nodes temporarily return to ordinary Go objects so they can later move into their own per-store typed arrays. vs 36a9163 (vscode, n=8, interleaved): │ old │ new │ │ check-sec │ check-sec vs base │ VSCode 7.630 ± 4% 8.047 ± 5% +5.47% (p=0.001 n=8) │ old │ new │ │ total-sec │ total-sec vs base │ VSCode 8.575 ± 2% 9.140 ± 5% +6.59% (p=0.000 n=8) │ old │ new │ │ mem-B │ mem-B vs base │ VSCode 3.375Gi ± 0% 3.367Gi ± 0% -0.23% (p=0.000 n=8)
A file's record array used to be clipped after parsing, grown again when the binder added its flow data records, and copied once more after binding. Records could therefore change and move after a file was handed out, which is only safe by convention. Binding depends only on the file, and every file in a program is bound anyway, so ParseSourceFile now binds immediately. Records are built in pooled scratch memory, bound while still private, and copied once into an exact-size array that is frozen from then on. Per-file bind trace events disappear, since binding is now part of parsing. vs 3402639 (vscode, n=8, interleaved): │ old │ new │ │ check-sec │ check-sec vs base │ VSCode 7.915 ± 4% 7.852 ± 2% ~ (p=1.000 n=8) │ old │ new │ │ total-sec │ total-sec vs base │ VSCode 8.963 ± 3% 8.798 ± 2% ~ (p=0.130 n=8) │ old │ new │ │ mem-B │ mem-B vs base │ VSCode 3.367Gi ± 0% 3.369Gi ± 0% +0.07% (p=0.002 n=8)
Flow nodes were still Go objects allocated from a per-store slab and
referenced from records through side tables of *FlowNode. That kept
millions of pointers for the GC to trace, plus an 8-byte side-table
entry and append slack per flow-node field, which undercuts the point
of pointer-free records.
FlowNode and FlowList are now {store, index} handles into pointer-free
per-store arrays, the same way Node is. Flow refs in records are plain
indices; checker-created flow nodes refer to file flow nodes through a
reused table, as reused nodes do.
vscode, n=8, interleaved, vs the previous commit:
│ old │ new │
│ check-sec │ check-sec vs base │
VSCode 7.991 ± 1% 8.090 ± 2% +1.23% (p=0.010 n=8)
│ old │ new │
│ total-sec │ total-sec vs base │
VSCode 8.904 ± 2% 8.951 ± 2% ~ (p=0.382 n=8)
│ old │ new │
│ mem-B │ mem-B vs base │
VSCode 3.369Gi ± 0% 3.271Gi ± 0% -2.89% (p=0.000 n=8)
Node lists were still heap objects: every NodeList and ModifierList
was a Go struct holding a []Node, each reached through a side table,
and the parser cloned every list's elements into a node-slice arena.
Together these were one of the largest remaining pointer-bearing parts
of the AST, all of which the GC had to trace.
Lists now live in the store's word array next to the nodes that own
them, as a range, a count, and packed 32-bit element refs. NodeList
and ModifierList become {store, index} views, so walking a list no
longer allocates or chases pointers, and the parser builds lists on a
single scratch stack rather than allocating a slice per list.
vscode, n=8, interleaved, vs the previous commit:
│ old │ new │
│ check-sec │ check-sec vs base │
VSCode 8.061 ± 2% 8.148 ± 3% ~ (p=0.161 n=8)
│ old │ new │
│ total-sec │ total-sec vs base │
VSCode 8.919 ± 2% 8.949 ± 3% ~ (p=0.820 n=8)
│ old │ new │
│ mem-B │ mem-B vs base │
VSCode 3.270Gi ± 0% 3.168Gi ± 0% -3.15% (p=0.000 n=8)
Since node lists became store records, every Slice() call allocates a
fresh []Node. Many checker, binder and language service paths only
need to test or scan the list, so materializing it is pure overhead.
Add NodeList.Some and NodeList.Every, have checkSourceElements and
FindUseStrictPrologue take a NodeList, and let IndexOfNode search the
record directly, so these paths no longer build temporary slices.
│ old │ new │
│ check-sec │ check-sec vs base │
VSCode 8.050 ± 2% 7.966 ± 2% -1.04% (p=0.005 n=8)
│ old │ new │
│ total-sec │ total-sec vs base │
VSCode 8.845 ± 1% 8.761 ± 2% -0.94% (p=0.041 n=8)
│ old │ new │
│ mem-B │ mem-B vs base │
VSCode 3.167Gi ± 0% 3.167Gi ± 0% ~ (p=0.798 n=8)
Kind() is the hottest AST accessor in the checker, and with index
handles each call loads the store, its words slice, bounds-checks and
reads the header. The handle has four bytes of padding after the
index, so storing the kind there makes Kind() a register read at no
size cost.
Using a full uint32 rather than a narrower type keeps the handle free
of padding, so Node-keyed maps still use the fast memory hash.
│ old │ new │
│ check-sec │ check-sec vs base │
VSCode 7.966 ± 2% 7.828 ± 1% -1.74% (p=0.050 n=8)
│ old │ new │
│ total-sec │ total-sec vs base │
VSCode 8.761 ± 2% 8.611 ± 1% -1.71% (p=0.021 n=8)
│ old │ new │
│ mem-B │ mem-B vs base │
VSCode 3.167Gi ± 0% 3.168Gi ± 0% ~ (p=0.574 n=8)
GetSourceFileOfNode walks parents up to the root, and the checker calls
it constantly, for instance in compareNodes. With node handles each
step of that walk costs several times what a pointer load costs on
main, and profiling shows the walk among the top AST costs.
A parsed file's nodes all live in its own store, so the store can
simply record the file and answer in one load. Walks from lazily
parsed JSDoc stop as soon as they reach the file's store.
│ old │ new │
│ check-sec │ check-sec vs base │
VSCode 7.618 ± 1% 7.484 ± 1% -1.76% (p=0.007 n=8)
│ old │ new │
│ total-sec │ total-sec vs base │
VSCode 8.395 ± 1% 8.279 ± 1% -1.39% (p=0.007 n=8)
│ old │ new │
│ mem-B │ mem-B vs base │
VSCode 3.167Gi ± 0% 3.168Gi ± 0% ~ (p=0.065 n=8)
Following a ref cost about 20 instructions: a store base load, an index
scale and a bounds check, against about 3 on main. Records now never
move: a store grows by adding chunks, and a handle holds the address of
its record, a real Go pointer that keeps the chunk alive. A ref is the
signed distance from the field to its target within one chunk, so
following it is a load, a test and an add. Refs that cross chunks or
stores go through the store's far table of handles.
This removes 6.7B of the 131B instructions single-threaded, but the
handle is still two words, and the store keeps the slack at the end of
its last chunk instead of copying to an exact size, so this is a step
toward an 8-byte handle rather than a win by itself.
│ sf.r.txt │ rel.r.txt │
│ check-sec │ check-sec vs base │
VSCode 7.861 ± 4% 7.745 ± 1% ~ (p=0.161 n=8)
│ sf.r.txt │ rel.r.txt │
│ total-sec │ total-sec vs base │
VSCode 8.631 ± 3% 8.719 ± 2% +1.02% (p=0.028 n=8)
│ sf.r.txt │ rel.r.txt │
│ mem-B │ mem-B vs base │
VSCode 3.167Gi ± 0% 3.278Gi ± 0% +3.50% (p=0.000 n=8)
A Node carried its store beside its record's address, so every handle
took two registers, every slice of nodes and map keyed by nodes was
twice main's size, and hashing hashed both words. The store is needed
only to follow far refs and reach side tables, so it now lives where
the record can find it: each chunk is a Go object whose one pointer is
a leading header naming its store, and each record header holds its
word offset from that header in what was padding.
Building chunk types with reflect.StructOf keeps the store pointer
typed, so the garbage collector still scans only the header, and no
pointer is hidden in an integer. Chunk sizes are rounded to a few
classes per power of two to bound the number of types.
│ rel.r.txt │ h8.r.txt │
│ check-sec │ check-sec vs base │
VSCode 7.747 ± 2% 7.493 ± 3% -3.28% (p=0.038 n=8)
│ rel.r.txt │ h8.r.txt │
│ total-sec │ total-sec vs base │
VSCode 8.704 ± 2% 8.357 ± 2% -3.99% (p=0.015 n=8)
│ rel.r.txt │ h8.r.txt │
│ mem-B │ mem-B vs base │
VSCode 3.278Gi ± 0% 3.228Gi ± 0% -1.55% (p=0.000 n=8)
Go rounds every chunk allocation up to its size class, and that
rounding was counted as used memory but never handed out as record
space. Picking chunk sizes that land exactly on a size class turns the
slack into usable words.
│ old.txt │ new.txt │
│ check-sec │ check-sec vs base │
VSCode 7.413 ± 1% 7.460 ± 1% ~ (p=0.245 n=8)
│ old.txt │ new.txt │
│ total-sec │ total-sec vs base │
VSCode 8.245 ± 1% 8.255 ± 1% ~ (p=0.959 n=8)
│ old.txt │ new.txt │
│ mem-B │ mem-B vs base │
VSCode 3.227Gi ± 0% 3.207Gi ± 0% -0.63% (p=0.000 n=8)
Members shared by many kinds, such as Expression, Name, Type and
Modifiers, were read through generated switches over the kind. Those
switches are too large to inline, and profiles of the checker showed
them among the most expensive functions in the ast package, which as a
whole took nearly twice the share of cycles that it does on main.
Every kind with such a member stores it at a fixed offset in its
record, so a table indexed by kind gives the offset, and reading the
member becomes a load from the table and a load from the record. The
kind tests behind DeclarationData and similar accessors become boolean
tables for the same reason.
│ old.txt │ new.txt │
│ check-sec │ check-sec vs base │
VSCode 7.369 ± 1% 7.229 ± 1% -1.89% (p=0.003 n=8)
│ old.txt │ new.txt │
│ total-sec │ total-sec vs base │
VSCode 8.183 ± 1% 8.036 ± 1% -1.79% (p=0.001 n=8)
│ old.txt │ new.txt │
│ mem-B │ mem-B vs base │
VSCode 3.208Gi ± 0% 3.207Gi ± 0% ~ (p=0.234 n=8)
Checker code walks up the tree constantly, mostly through FindAncestor
with a closure. On main, FindAncestor is inlined into its callers,
which then call the closure directly; here, reading a parent cost too
much for that, so every walk went through an extra call.
Two encoding details made reading a ref expensive. A zero ref needed
its own test before the near case, and the chunk offset in the header
was in words and had to be scaled. Marking near refs with the low bit
and keeping the absent node in the first far slot lets zero fall into
the far case with no test, and storing the chunk offset in bytes drops
the scaling. With Parent written out directly, FindAncestor and
FindAncestorKind fit the inlining budget again.
│ old.txt │ new.txt │
│ check-sec │ check-sec vs base │
VSCode 7.275 ± 0% 7.160 ± 1% -1.58% (p=0.000 n=8)
│ old.txt │ new.txt │
│ total-sec │ total-sec vs base │
VSCode 8.096 ± 0% 7.969 ± 1% -1.57% (p=0.000 n=8)
│ old.txt │ new.txt │
│ mem-B │ mem-B vs base │
VSCode 3.207Gi ± 0% 3.208Gi ± 0% ~ (p=0.505 n=8)
On main, Node.Symbol, Locals, Body, Expression and similar accessors
are small enough to inline, so the checker reads a member with a few
loads. Here they were all out of line: reading a member took a kind
table lookup, a ref decode and a store lookup, and several also built
a panic message inline, which the inliner charges as a call.
Symbol, Locals and Body now use the same offset tables as Name and
Expression; the JSDoc tags whose Type is their type expression, and the
kinds that never had a Body, are expressed as overrides of the tables.
Side tables keep their zero value in their first slot so that reading
one needs no test, deref finds the store without the chunk helpers,
and panics carry the kind and build their message only when printed.
Wall-clock runs were too noisy to separate the change; single-threaded
runs with the collector off measure it:
│ old.txt │ new.txt │
│ insns │ insns vs base │
VSCode 114.9G ± ∞ ¹ 113.7G ± ∞ ¹ -1.11% (p=0.008 n=5)
¹ need >= 6 samples for confidence interval at level 0.95
│ old.txt │ new.txt │
│ task-sec │ task-sec vs base │
VSCode 24.79 ± ∞ ¹ 24.53 ± ∞ ¹ -1.03% (p=0.008 n=5)
¹ need >= 6 samples for confidence interval at level 0.95
Jake Bailey (jakebailey)
force-pushed
the
arena-ast
branch
from
October 17, 2026 04:10
c684744 to
b65f487
Compare
Reading a string field, such as an identifier's text, chose between
the source and the arena and, for the arena, computed which of its
geometrically sized chunks held the string. That was too much to
inline, and Identifier.Text alone accounted for almost a billion
instructions checking VS Code that main spends on a single load.
The store's text is now addressed as a table of pieces: the source's
pieces first, so source positions are their own offsets, then the
arena's. Every string is found from the start of its piece with no
test, and Identifier.Text and the other text accessors inline. Node.Text
reads the text of identifiers and literals through an offset table
like the other shared members, leaving the switch for the kinds whose
text it computes.
│ old.txt │ new.txt │
│ insns │ insns vs base │
VSCode 113.7G ± 0% 113.2G ± 0% -0.44% (p=0.002 n=6)
│ old.txt │ new.txt │
│ task-sec │ task-sec vs base │
VSCode 25.21 ± 6% 24.92 ± 6% ~ (p=0.093 n=6)
│ old.txt │ new.txt │
│ check-sec │ check-sec vs base │
VSCode 7.343 ± 3% 7.275 ± 1% ~ (p=0.234 n=8)
│ old.txt │ new.txt │
│ total-sec │ total-sec vs base │
VSCode 8.158 ± 3% 8.107 ± 1% ~ (p=0.442 n=8)
│ old.txt │ new.txt │
│ mem-B │ mem-B vs base │
VSCode 3.209Gi ± 0% 3.212Gi ± 0% ~ (p=0.083 n=8)
Walking up the tree is the most common way of following a ref, and the
nodeRef encoding made each near step pay for its bias: the distance is
measured from the field and the low bit is set, so the target is the
node plus a constant plus the ref. Go splits three-operand LEAs, so that
took two instructions on top of the load, sign extension and test.
The parent gets its own encoding instead: an even ref is the distance
from the node itself, which can never be zero, and an odd ref is a far
slot, with 1 as no parent. Records start with no parent.
│ old.txt │ new.txt │
│ insns │ insns vs base │
VSCode 113.2G ± 0% 112.8G ± ∞ ¹ -0.32% (p=0.004 n=6+5)
│ old.txt │ new.txt │
│ task-sec │ task-sec vs base │
VSCode 24.91 ± 1% 24.89 ± ∞ ¹ ~ (p=0.537 n=6+5)
A zero ref was decoded as far slot zero so that deref needed no test of
its own, but absent fields are common (most nodes have no modifiers, for
instance), and reading the slot takes three dependent loads through the
chunk header and the store, far from the record. Profiles of
ModifierFlags showed most of its time there.
Testing for zero first costs the inliner a little, so the accessors that
were at the limit are trimmed to stay inlinable: their panic values are
named kinds rather than structs, the generated member accessors use the
record pointer directly, and base views' Modifiers use Node.Modifiers,
which the views' kinds all have.
│ old.txt │ new.txt │
│ insns │ insns vs base │
VSCode 112.8G ± 0% 112.6G ± 0% -0.21% (p=0.002 n=6)
│ old.txt │ new.txt │
│ task-sec │ task-sec vs base │
VSCode 24.78 ± 1% 24.72 ± 1% ~ (p=0.699 n=6)
Main inlines NodeIdGenerator.GetNodeId with a cost of 79, at the limit.
Reaching the header through hdr() three times pushed it to 91, making
every node id lookup in the checker a call. Taking the header once
brings it back to 79.
│ old.txt │ new.txt │
│ insns │ insns vs base │
VSCode 112.5G ± 0% 112.2G ± 0% -0.28% (p=0.002 n=6)
│ old.txt │ new.txt │
│ task-sec │ task-sec vs base │
VSCode 24.55 ± 1% 24.49 ± 1% ~ (p=1.000 n=6)
The doc still described the {store, index} handle that the branch
moved away from in step 19. It now describes the record-address design,
its ref encodings and remaining unsafe, where the gap to main comes
from, and the numbers at the current tip.
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