Skip to content

perf(semantic): make building AST nodes optional via parent-pointer storage#22859

Closed
Boshen wants to merge 1 commit into
mainfrom
perf/semantic-optional-ast-nodes
Closed

perf(semantic): make building AST nodes optional via parent-pointer storage#22859
Boshen wants to merge 1 commit into
mainfrom
perf/semantic-optional-ast-nodes

Conversation

@Boshen

@Boshen Boshen commented May 31, 2026

Copy link
Copy Markdown
Member

What

SemanticBuilder can now build without retaining every AST node. It defaults to a lightweight ancestor-stack storage that keeps only the live root..=current chain during traversal. Consumers that need random access to nodes after the build opt back in with .with_ast_nodes(true) (and .with_cfg(true) forces full storage automatically, since CFG construction needs per-node data).

This makes building AST nodes optional for pipelines that only consume Scoping: the oxc compiler pipeline, transform, minify, and define/inject all call into_scoping() and discard the nodes today, so building them was wasted work.

How

A new NodeStorage enum in oxc_semantic (node/storage.rs) with two backends:

  • Full(AstNodes) — today's flat random-access storage, retained after the build.
  • Ancestors { stack, len } — a Stack of the live ancestor chain, pushed on enter / popped on exit, serving the parent/ancestor queries the binder, class-table builder, and syntax checker need (they only ever look upward).

Both allocate NodeIds from the same monotonic counter, so Scoping is identical regardless of mode. Semantic::nodes() is empty in ancestor-stack mode; a new node_count field keeps Semantic::stats() accurate so with_stats reuse still pre-allocates correctly.

Redeclaration checks (the only build-time reads of an already-popped node)

Two redeclaration checks used to dereference a previously declared (already-popped) function node. Neither touches AstNodes any more, so both behave identically in either storage mode:

Every other build-time node read resolves the current node, the enclosing function, an enclosing scope's node, or the enclosing class's node — all guaranteed to be on the live ancestor stack.

Consumers

  • Stack (default): compiler pipeline, compressor, transformer, define/inject rebuilds, napi parser/transform, oxlint parse-check.
  • Full (opt-in): mangler (+ minifier mangle path), formatter detect_code_removal, playground, coverage driver (via a new CompilerInterface::build_ast_nodes() hook), and semantic's own tests/examples. The linter already builds with with_cfg(true), so it gets full storage automatically.

Validation

  • oxc_semantic, mangler, minifier, transformer, and codegen tests pass.
  • Conformance: zero regressionscargo coverage -- semantic leaves the snapshot failure-counts byte-identical (and it exercises check_redeclaration in full-node mode, so unchanged snapshots prove the NodeId comparison matches the original node read).
  • clippy, rustdoc -D warnings, and cargo fmt clean.

Note

The win is build-local: into_scoping() already freed nodes before transform, so this trims the per-node AstNodes allocations/writes during each pipeline semantic build, not transform/codegen themselves.


🤖 This PR was authored with Claude Code.

@github-actions github-actions Bot added A-linter Area - Linter A-semantic Area - Semantic A-minifier Area - Minifier A-formatter Area - Formatter labels May 31, 2026
@codspeed-hq

codspeed-hq Bot commented May 31, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 4.99%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 7 regressed benchmarks
✅ 50 untouched benchmarks
⏩ 9 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation pipeline[App.tsx] 34.3 ms 36.4 ms -5.77%
Simulation semantic[App.tsx] 5.3 ms 5.7 ms -5.69%
Simulation pipeline[binder.ts] 13.3 ms 14 ms -5.18%
Simulation semantic[react.development.js] 1 ms 1.1 ms -4.96%
Simulation semantic[binder.ts] 2.5 ms 2.6 ms -4.93%
Simulation pipeline[react.development.js] 6.4 ms 6.7 ms -4.34%
Simulation pipeline[RadixUIAdoptionSection.jsx] 320.9 µs 334.4 µs -4.06%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/semantic-optional-ast-nodes (3831e17) with main (17e7cf3)

Open in CodSpeed

Footnotes

  1. 9 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

…torage

`SemanticBuilder` now defaults to a lightweight ancestor-stack node storage that retains only the live `root..=current` chain instead of every AST node. Consumers that need random access to nodes after the build (linter, formatter, mangler) opt in with `with_ast_nodes(true)`; `with_cfg(true)` forces full storage too.

Scoping is produced identically in both modes, so pipelines that discard nodes and keep only `Scoping` (transform, minify, define/inject) avoid the per-node allocations of full storage.
@Boshen Boshen force-pushed the perf/semantic-optional-ast-nodes branch from 5c6ccff to 3831e17 Compare June 5, 2026 15:04
@Boshen Boshen closed this Jun 8, 2026
@Boshen Boshen deleted the perf/semantic-optional-ast-nodes branch June 8, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-formatter Area - Formatter A-linter Area - Linter A-minifier Area - Minifier A-semantic Area - Semantic

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant