Skip to content

perf(formatter): stage IR element buffers on the heap instead of the arena#24556

Closed
Boshen wants to merge 1 commit into
mainfrom
perf/formatter-heap-staging
Closed

perf(formatter): stage IR element buffers on the heap instead of the arena#24556
Boshen wants to merge 1 commit into
mainfrom
perf/formatter-heap-staging

Conversation

@Boshen

@Boshen Boshen commented Jul 15, 2026

Copy link
Copy Markdown
Member

The allocation-tracking snapshots show the formatter's arena reaching 525 MB for antd.js (6.7 MB source) — ~18× the parser's arena for the same file, while the live document is only ~61 MB. The arena is a bump allocator: any vector that grows inside it strands every grown-out-of allocation for the rest of the format run, and in-place growth almost never applies because other allocations (AstNode wrappers, nested buffers) land in between.

Per-phase measurement of Allocator::used_bytes() attributed the garbage to:

  • intern() staging (~270 MB on antd.js): every interned island was built in an arena VecBuffer growing from capacity 0. The default call-arguments path interns each call's argument list, and a UMD bundle is one giant nested call expression, so nearly the whole document flows through interns (the root slice ends up with 294 elements; 67k interned islands hold the rest).
  • Root buffer pre-allocation (~64 MB on antd.js): format() reserves 0.4×source-length elements in the arena up front, which is dead weight whenever content ends up in interned islands instead.
  • (~71 MB of transient AstNode wrappers — not addressed here, needs a design change.)

Change

New HeapVecBuffer in oxc_formatter_core: a heap-backed staging twin of VecBuffer, drawing its backing vector from a LIFO scratch pool on FormatState (one buffer per nesting level, capacity retained across uses, so staging allocates nothing in the steady state). Sequences whose length isn't known up front are built on the heap — where growth reallocations are actually freed — and the arena receives one exactly-sized copy of the final content.

Converted call sites:

  • Formatter::intern
  • BestFitting variant construction
  • the three grouped-argument variant builders in arguments.rs
  • the assignment-like left-hand-side staging (replayed into the parent buffer via take_heap_vec; nothing lands in the arena at all)
  • both document roots: JS format() and JSON format() (resolves the TODO there about pre-sizing)

VecBuffer::take_vec lost its last caller and is removed.

Results

Arena bytes after parse + IR build (Allocator::used_bytes()), tracked benchmark files:

file before after
antd.js 363.2 MB 180.3 MB −50%
checker.ts 104.0 MB 77.3 MB −26%
App.tsx 21.7 MB 14.6 MB −33%
pdf.mjs 35.0 MB 28.7 MB −18%
kitchen-sink.tsx 45.4 MB 38.8 MB −15%
binder.ts 6.5 MB 4.8 MB −26%

Output is byte-identical on all of the files above, fixture/unit suites for oxc_formatter and oxc_formatter_json pass, and hyperfine shows parity to 1.03× faster (antd.js 146.8 → 142.9 ms mean); the heap-side churn stays flat thanks to the scratch pool.

The tasks/track_memory_allocations snapshots are not regenerated here — their byte lines are Linux-CI reference values, so they need the CI job's diff.


Investigated and implemented with Claude Code; reviewed and driven by me.

@github-actions github-actions Bot added the A-formatter Area - Formatter label Jul 15, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 15, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 3.54%

⚡ 1 improved benchmark
❌ 7 regressed benchmarks
✅ 44 untouched benchmarks
⏩ 19 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation formatter[errors.ts] 544.5 µs 583.6 µs -6.69%
Simulation formatter[core.js] 1.5 ms 1.6 ms -6.27%
Simulation formatter[handle-comments.js] 2.6 ms 2.7 ms -5.11%
Simulation formatter[index.tsx] 3.5 ms 3.7 ms -4.4%
Simulation formatter[next.ts] 2.1 ms 2.2 ms -4.39%
Simulation formatter[Search.tsx] 1.6 ms 1.6 ms -3.64%
Simulation formatter[App.tsx] 47.3 ms 49 ms -3.36%
Simulation formatter[types.ts] 12.5 ms 11.8 ms +6.13%

Tip

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


Comparing perf/formatter-heap-staging (7070660) with main (246b426)2

Open in CodSpeed

Footnotes

  1. 19 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.

  2. No successful run was found on main (4b2619d) during the generation of this report, so 246b426 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@Boshen

Boshen commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

Closing in favor of #24558 — the heap staging regressed CPU across the formatter benches (the final heap→arena copy is element-wise via from_iter_in(drain(..)), and the root document copy is new work entirely). The issue has the profile attribution, local A/B numbers, and the fix direction.

@Boshen Boshen closed this Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-formatter Area - Formatter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant