Skip to content

6.4x speedup of AST materialization in JS API#2835

Open
auvred wants to merge 9 commits intomicrosoft:mainfrom
auvred:speedup-ast-materialization
Open

6.4x speedup of AST materialization in JS API#2835
auvred wants to merge 9 commits intomicrosoft:mainfrom
auvred:speedup-ast-materialization

Conversation

@auvred
Copy link
Contributor

@auvred auvred commented Feb 19, 2026

  • make id a getter
  • define index access getters on RemoteNodeList only for lists with >15 nodes
  • don't compute source file by walking up the tree in every RemoteNode constructor call
  • initialize all source file nodes in one hot loop
  • don't cache child nodes in dynamically created maps; refer to the source file's nodes array instead

On main, the materialize program.ts and materialize checker.ts benchmarks are incorrect because they don't reset the source file's _children cache.

To compare this PR with main, add file._children = undefined to the benchmark code, like so:

        .add("materialize checker.ts", async () => {
            file._children = undefined;
            file.forEachChild(function visit(node) {
                node.forEachChild(visit);
            });
        })

Benchmarks on main (incorrect):

┌─────────┬──────────────────────────┬─────────────────────┬─────────────────────┬────────────────────────┬────────────────────────┬─────────┐
│ (index) │ Task name                │ Latency avg (ns)    │ Latency med (ns)    │ Throughput avg (ops/s) │ Throughput med (ops/s) │ Samples │
├─────────┼──────────────────────────┼─────────────────────┼─────────────────────┼────────────────────────┼────────────────────────┼─────────┤
│ 0       │ 'materialize program.ts' │ '1662097 ± 3.60%'   │ '1521254 ± 89851'   │ '634 ± 1.22%'          │ '657 ± 39'             │ 602     │
│ 1       │ 'materialize checker.ts' │ '72855176 ± 41.99%' │ '57846306 ± 200516' │ '16 ± 12.85%'          │ '17 ± 0'               │ 14      │
└─────────┴──────────────────────────┴─────────────────────┴─────────────────────┴────────────────────────┴────────────────────────┴─────────┘

Benchmarks on main (fixed):

┌─────────┬──────────────────────────┬─────────────────────┬───────────────────────┬────────────────────────┬────────────────────────┬─────────┐
│ (index) │ Task name                │ Latency avg (ns)    │ Latency med (ns)      │ Throughput avg (ops/s) │ Throughput med (ops/s) │ Samples │
├─────────┼──────────────────────────┼─────────────────────┼───────────────────────┼────────────────────────┼────────────────────────┼─────────┤
│ 0       │ 'materialize program.ts' │ '16393483 ± 4.59%'  │ '14681675 ± 425336'   │ '63 ± 3.74%'           │ '68 ± 2'               │ 61      │
│ 1       │ 'materialize checker.ts' │ '250615850 ± 2.54%' │ '247339115 ± 4464518' │ '4 ± 2.46%'            │ '4 ± 0'                │ 10      │
└─────────┴──────────────────────────┴─────────────────────┴───────────────────────┴────────────────────────┴────────────────────────┴─────────┘

Benchmarks on this branch:

┌─────────┬──────────────────────────┬─────────────────────┬──────────────────────┬────────────────────────┬────────────────────────┬─────────┐
│ (index) │ Task name                │ Latency avg (ns)    │ Latency med (ns)     │ Throughput avg (ops/s) │ Throughput med (ops/s) │ Samples │
├─────────┼──────────────────────────┼─────────────────────┼──────────────────────┼────────────────────────┼────────────────────────┼─────────┤
│ 0       │ 'materialize program.ts' │ '2553450 ± 2.69%'   │ '2396055 ± 30939'    │ '405 ± 1.24%'          │ '417 ± 5'              │ 392     │
│ 1       │ 'materialize checker.ts' │ '38940759 ± 10.35%' │ '39447118 ± 9466677' │ '27 ± 10.05%'          │ '25 ± 5'               │ 26      │
└─────────┴──────────────────────────┴─────────────────────┴──────────────────────┴────────────────────────┴────────────────────────┴─────────┘
  • parser.ts - 16.393ms -> 2.553ms (6.42x)
  • checker.ts - 250.616ms -> 38.941ms (6.43x)

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes the _packages/api JS client-side “remote AST” materialization path, aiming to significantly reduce the overhead of constructing RemoteSourceFile/RemoteNode trees, and updates the benchmarks to measure that materialization work more directly.

Changes:

  • Updates the sync/async benchmarks to “materialize” by reconstructing a fresh RemoteSourceFile from the underlying binary buffer instead of walking/caching children.
  • Refactors RemoteNode/RemoteNodeList to reduce per-node/list work (e.g., id becomes a getter, children are sourced from a pre-built nodes array, and index accessors are adjusted).
  • Adds a hot-loop initialization in RemoteSourceFile to pre-create node wrappers and avoid per-access map caching.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.

File Description
_packages/api/test/api.sync.bench.ts Adjusts materialization benchmark to reconstruct RemoteSourceFile from the binary backing data.
_packages/api/test/api.async.bench.ts Same as sync benchmark, but for the async API bench harness.
_packages/api/src/node.ts Implements new materialization strategy (pre-built nodes array), changes id to getters, and revises RemoteNodeList index accessor behavior.

Comment on lines +317 to +338
return this.at(0);
}
get 1(): RemoteNode {
return this.at(1);
}
get 2(): RemoteNode {
return this.at(2);
}
get 3(): RemoteNode {
return this.at(3);
}
get 4(): RemoteNode {
return this.at(4);
}
get 5(): RemoteNode {
return this.at(5);
}
get 6(): RemoteNode {
return this.at(6);
}
get 7(): RemoteNode {
return this.at(7);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RemoteNodeList now defines fixed index getters (get 0()get 15()) unconditionally. For lists with length < 16 (including length === 0), these getters will still call this.at(n) and can return an unrelated node instead of undefined, changing normal array out-of-bounds behavior (e.g. list[0] on an empty list). Add bounds checks in these getters (or only define them when n < this.length).

Suggested change
return this.at(0);
}
get 1(): RemoteNode {
return this.at(1);
}
get 2(): RemoteNode {
return this.at(2);
}
get 3(): RemoteNode {
return this.at(3);
}
get 4(): RemoteNode {
return this.at(4);
}
get 5(): RemoteNode {
return this.at(5);
}
get 6(): RemoteNode {
return this.at(6);
}
get 7(): RemoteNode {
return this.at(7);
return this.length > 0 ? this.at(0) : (undefined as any);
}
get 1(): RemoteNode {
return this.length > 1 ? this.at(1) : (undefined as any);
}
get 2(): RemoteNode {
return this.length > 2 ? this.at(2) : (undefined as any);
}
get 3(): RemoteNode {
return this.length > 3 ? this.at(3) : (undefined as any);
}
get 4(): RemoteNode {
return this.length > 4 ? this.at(4) : (undefined as any);
}
get 5(): RemoteNode {
return this.length > 5 ? this.at(5) : (undefined as any);
}
get 6(): RemoteNode {
return this.length > 6 ? this.at(6) : (undefined as any);
}
get 7(): RemoteNode {
return this.length > 7 ? this.at(7) : (undefined as any);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is consistent with old approach.

Object.defineProperty(this, i, {
get() {
return this.at(i);
},
});

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, but it looks like at itself may have a bug for out-of-bounds indexes, which is now replicated onto accessors from 0–16. Would you mind adding the bounds check into at?

@andrewbranch
Copy link
Member

initialize all source file nodes in one hot loop

This is a good way to make the benchmark faster, but that benchmark is intended to represent a worst-case scenario where someone touches every node. It was designed to be lazy on purpose. Imagine this being used in an auxiliary LSP server where someone registers some additional completions or refactors. Those handlers start with a document position, so in a file like checker.ts, a realistic implementation will likely only have to materialize a tiny, tiny fraction of all the nodes in the file during any given request.

@auvred
Copy link
Contributor Author

auvred commented Feb 19, 2026

This is a good way to make the benchmark faster, but that benchmark is intended to represent a worst-case scenario where someone touches every node. It was designed to be lazy on purpose. Imagine this being used in an auxiliary LSP server where someone registers some additional completions or refactors. Those handlers start with a document position, so in a file like checker.ts, a realistic implementation will likely only have to materialize a tiny, tiny fraction of all the nodes in the file during any given request.

I'm currently researching the performance of tsgo-powered JS lint rules. From my private evaluations (which I plan to opensource soon), lint rules tend to cumulatively touch up to 80% of all AST nodes (with a few exception rules that touch almost 100% of AST nodes).

Maybe we can add some kind of opt-in eager materialization for these usecases?

On main, lazy materialization of an already parsed AST is slower than parsing the source file from scratch with Strada; this doesn't seem right to me.

@andrewbranch
Copy link
Member

Yeah, an option to do that would be great! It makes sense that a linter would want to look at the whole file.

@auvred
Copy link
Contributor Author

auvred commented Feb 19, 2026

Ok, it turns out that full AST traversal + lazy materialization is now only a few percent slower than bulk materialization + full AST traversal. Lint rules still have to do a full AST traversal, so there's no big win from bulk materialization. I reverted getOrCreateChildAtNodeIndex back to the lazy approach; it's still fast!

super(view, decoder, 1, undefined!, {} as unknown as RemoteSourceFile);
this.sourceFile = this;
this.nodes = Array((this.view.byteLength - this.offsetNodes) / NODE_LEN);
this.nodes[0] = new RemoteNode(this.view, this.decoder, 0, this, this);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this right? The 0-index slot is supposed to indicate an undefined node. I would have thought something would have broken 🤔

get id(): string {
const extendedDataOffset = this.offsetExtendedData + (this.data & NODE_EXTENDED_DATA_MASK);
return this.getString(this.view.getUint32(extendedDataOffset + 12, true));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just merged something that will conflict with this in #2831, sorry. Should be a straightforward removal.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments