perf(formatter): stage IR element buffers on the heap instead of the arena#24556
perf(formatter): stage IR element buffers on the heap instead of the arena#24556Boshen wants to merge 1 commit into
Conversation
Merging this PR will degrade performance by 3.54%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
|
Closing in favor of #24558 — the heap staging regressed CPU across the formatter benches (the final heap→arena copy is element-wise via |
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 arenaVecBuffergrowing 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).format()reserves 0.4×source-length elements in the arena up front, which is dead weight whenever content ends up in interned islands instead.AstNodewrappers — not addressed here, needs a design change.)Change
New
HeapVecBufferinoxc_formatter_core: a heap-backed staging twin ofVecBuffer, drawing its backing vector from a LIFO scratch pool onFormatState(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::internBestFittingvariant constructionarguments.rstake_heap_vec; nothing lands in the arena at all)format()and JSONformat()(resolves theTODOthere about pre-sizing)VecBuffer::take_veclost its last caller and is removed.Results
Arena bytes after parse + IR build (
Allocator::used_bytes()), tracked benchmark files:Output is byte-identical on all of the files above, fixture/unit suites for
oxc_formatterandoxc_formatter_jsonpass, 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_allocationssnapshots 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.