Skip to content

AST nodes as handles, offsets into areanas - #64698

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

Jake Bailey (jakebailey) wants to merge 38 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)
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
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

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