Problem
tasks/track_memory_allocations/allocs_formatter.snap (metric added in #24553) shows the formatter's arena footprint is enormous — 45–80x the source file size, an order of magnitude more than the AST it formats:
| file |
source |
arena after formatting |
×source |
| antd.js |
6.69 MB |
525.73 MB |
79x |
| checker.ts |
2.92 MB |
138.51 MB |
47x |
| kitchen-sink.tsx |
732.90 kB |
57.89 MB |
79x |
| pdf.mjs |
567.30 kB |
44.87 MB |
79x |
| App.tsx |
415.34 kB |
29.61 MB |
71x |
| binder.ts |
193.08 kB |
8.57 MB |
44x |
| RadixUIAdoptionSection.jsx |
2.52 kB |
210.08 kB |
83x |
This is visible in real-world comparisons: in bench-formatter, oxfmt's peak RSS is consistently ~1.5–1.8x Biome's (105.1 vs 70.1 MB on the single-large-file fixture, 139.9 vs 79.6 MB on JS/TS, 596.4 MB on the embedded-languages fixture), and oxfmt formats files in parallel, so per-file arena blowup multiplies across worker threads.
Stage split (measured)
Reading allocator.used_bytes() between stages (macOS aarch64 — absolute values ~25–30% below the Linux x64 snapshot per the platform note in #24553; ratios match):
| file |
after parse |
after IR build (format_program) |
after print() |
root document elements |
| antd.js |
29.07 MB |
363.21 MB (+334.1 MB) |
+0 B |
294 |
| checker.ts |
12.89 MB |
103.94 MB (+91.0 MB) |
+0 B |
622,827 |
| kitchen-sink.tsx |
6.43 MB |
45.41 MB (+39.0 MB) |
+0 B |
291,479 |
| pdf.mjs |
4.52 MB |
34.96 MB (+30.4 MB) |
+0 B |
194,981 |
| App.tsx |
2.23 MB |
21.67 MB (+19.4 MB) |
+0 B |
63,007 |
| binder.ts |
0.91 MB |
6.45 MB (+5.5 MB) |
+0 B |
38,575 |
Two facts fall out:
- Printing allocates zero arena. The entire problem is IR build —
format_program adds 7–11x the size of the AST it walks.
- antd.js's root document has 294 elements. It's a webpack UMD bundle — one top-level
(function webpackUniversalModuleDefinition(root, factory) {…})(this, function () {…}) call — so essentially the entire file's IR is built inside nested speculative buffers rather than the root stream.
Where the memory goes
- Staging growth strands arena memory. All IR staging buffers (
Formatter::intern, BestFitting variants, grouped call arguments) are ArenaVecs that start at capacity 0. The arena is a bump allocator: every growth allocates a new block and abandons the old one for the rest of the run (in-place growth requires nothing else was bumped in between — practically never true mid-format). antd.js formatting performs 302,459 arena reallocs, each stranding its old block; with doubling growth, a sequence that ends at n bytes strands ~n bytes along the way.
- Grouped call arguments materialize up to three variants. For a call whose first/last argument is a function/arrow,
write_grouped_arguments builds most_flat / middle_variant / most_expanded as three separate arena slices — three full copies of the argument IR (the flat one additionally staged through RemoveSoftLinesBuffer). For bundle-shaped files this is the whole story: antd.js's outermost UMD call routes ~the entire document through this path, consistent with IR build costing 334 MB when the live document is a fraction of that. Callback-heavy code pays the same on every such call site.
- The root buffer pre-allocation.
oxc_formatter::format pre-sizes the root buffer at source_len * 2 / 5 elements × 24 B/element ≈ 9.6 bytes per source byte (64 MB for antd.js — mostly dead there, since the root holds 294 elements).
- The live IR itself is large.
FormatElement is 24 bytes; documents run to millions of elements (622K in checker.ts's root alone, plus every interned subsequence as its own arena slice).
AstNode parent-pointer wrappers — every formatted node allocates a wrapper in the arena (part of antd.js's 2.59M arena allocs during formatting).
Prior attempt: #24556 (closed)
#24556 moved staging buffers onto pooled heap Vecs — heap growth is reclaimed by the system allocator, killing factor 1 — but regressed CPU: CodSpeed −3.4..−6.7%, local wall time +4..+7.8% on every bench file. Attribution (samply): the final heap→arena copy used ArenaVec::from_iter_in(drain(..)), a per-element loop (oxc_allocator::Vec has no TrustedLen/slice specialization — each element pays iterator next + capacity check + ptr::write + set_len), and heap-staging the root document added a copy main never does (the root ArenaVec moves into Document for free).
Constraints for a retry:
- The heap→arena move must be a bulk
memcpy — e.g. ptr::copy_nonoverlapping (sound: FormatElement is not Drop; ArenaVec const-asserts !needs_drop), or derive Copy for FormatElement (all fields are arena refs/small scalars) and use extend_from_slice_copy, or add a Vec::from_std_vec_in bulk-move API to oxc_allocator:
pub fn take_into_arena_vec(&mut self) -> ArenaVec<'ast, FormatElement<'ast>> {
+ let len = self.elements.len();
let allocator = self.state.allocator();
- ArenaVec::from_iter_in(self.elements.drain(..), &allocator)
+ let mut vec = ArenaVec::with_capacity_in(len, &allocator);
+ // SAFETY: `vec` has capacity for `len` elements and the two buffers (heap vs arena)
+ // cannot overlap. `FormatElement` is not `Drop` (`ArenaVec` const-asserts it), so the
+ // bitwise move duplicates no owning state and `clear()` merely forgets the moved-out
+ // elements.
+ unsafe {
+ std::ptr::copy_nonoverlapping(self.elements.as_ptr(), vec.as_mut_ptr(), len);
+ vec.set_len(len);
+ }
+ self.elements.clear();
+ vec
}
- Keep the root document staged in the arena as today; heap-stage only
intern / BestFitting / grouped-arguments, where the capacity-0 growth chains actually strand memory. The root pre-size heuristic (factor 3) can be tuned independently.
- CodSpeed's instruction simulation and native wall time disagreed on types.ts (+6.1% vs −6.6%) — validate on both.
Note heap staging only removes the stranding (factor 1); factors 2–4 (three variant copies, root pre-size, live IR volume) are untouched by it.
Other directions
- Two-arena scheme: stage in a scratch
Allocator reset between top-level statements, bulk-copying finished sequences into the document arena — removes stranding without system-allocator traffic.
- Share subtrees between best-fitting variants instead of materializing three full copies — the biggest lever for bundle-shaped files.
- Reduce live IR volume: shrink
FormatElement, emit fewer elements.
Measurements: tasks/track_memory_allocations (snapshot = Linux x64; stage split = macOS aarch64), bench-formatter README (2026-07). Investigation done with Claude.
Problem
tasks/track_memory_allocations/allocs_formatter.snap(metric added in #24553) shows the formatter's arena footprint is enormous — 45–80x the source file size, an order of magnitude more than the AST it formats:This is visible in real-world comparisons: in bench-formatter, oxfmt's peak RSS is consistently ~1.5–1.8x Biome's (105.1 vs 70.1 MB on the single-large-file fixture, 139.9 vs 79.6 MB on JS/TS, 596.4 MB on the embedded-languages fixture), and oxfmt formats files in parallel, so per-file arena blowup multiplies across worker threads.
Stage split (measured)
Reading
allocator.used_bytes()between stages (macOS aarch64 — absolute values ~25–30% below the Linux x64 snapshot per the platform note in #24553; ratios match):format_program)print()Two facts fall out:
format_programadds 7–11x the size of the AST it walks.(function webpackUniversalModuleDefinition(root, factory) {…})(this, function () {…})call — so essentially the entire file's IR is built inside nested speculative buffers rather than the root stream.Where the memory goes
Formatter::intern,BestFittingvariants, grouped call arguments) areArenaVecs that start at capacity 0. The arena is a bump allocator: every growth allocates a new block and abandons the old one for the rest of the run (in-place growth requires nothing else was bumped in between — practically never true mid-format). antd.js formatting performs 302,459 arena reallocs, each stranding its old block; with doubling growth, a sequence that ends at n bytes strands ~n bytes along the way.write_grouped_argumentsbuildsmost_flat/middle_variant/most_expandedas three separate arena slices — three full copies of the argument IR (the flat one additionally staged throughRemoveSoftLinesBuffer). For bundle-shaped files this is the whole story: antd.js's outermost UMD call routes ~the entire document through this path, consistent with IR build costing 334 MB when the live document is a fraction of that. Callback-heavy code pays the same on every such call site.oxc_formatter::formatpre-sizes the root buffer atsource_len * 2 / 5elements × 24 B/element ≈ 9.6 bytes per source byte (64 MB for antd.js — mostly dead there, since the root holds 294 elements).FormatElementis 24 bytes; documents run to millions of elements (622K in checker.ts's root alone, plus every interned subsequence as its own arena slice).AstNodeparent-pointer wrappers — every formatted node allocates a wrapper in the arena (part of antd.js's 2.59M arena allocs during formatting).Prior attempt: #24556 (closed)
#24556 moved staging buffers onto pooled heap
Vecs — heap growth is reclaimed by the system allocator, killing factor 1 — but regressed CPU: CodSpeed −3.4..−6.7%, local wall time +4..+7.8% on every bench file. Attribution (samply): the final heap→arena copy usedArenaVec::from_iter_in(drain(..)), a per-element loop (oxc_allocator::Vechas noTrustedLen/slice specialization — each element pays iteratornext+ capacity check +ptr::write+set_len), and heap-staging the root document added a copymainnever does (the rootArenaVecmoves intoDocumentfor free).Constraints for a retry:
memcpy— e.g.ptr::copy_nonoverlapping(sound:FormatElementis notDrop;ArenaVecconst-asserts!needs_drop), or deriveCopyforFormatElement(all fields are arena refs/small scalars) and useextend_from_slice_copy, or add aVec::from_std_vec_inbulk-move API tooxc_allocator:pub fn take_into_arena_vec(&mut self) -> ArenaVec<'ast, FormatElement<'ast>> { + let len = self.elements.len(); let allocator = self.state.allocator(); - ArenaVec::from_iter_in(self.elements.drain(..), &allocator) + let mut vec = ArenaVec::with_capacity_in(len, &allocator); + // SAFETY: `vec` has capacity for `len` elements and the two buffers (heap vs arena) + // cannot overlap. `FormatElement` is not `Drop` (`ArenaVec` const-asserts it), so the + // bitwise move duplicates no owning state and `clear()` merely forgets the moved-out + // elements. + unsafe { + std::ptr::copy_nonoverlapping(self.elements.as_ptr(), vec.as_mut_ptr(), len); + vec.set_len(len); + } + self.elements.clear(); + vec }intern/BestFitting/ grouped-arguments, where the capacity-0 growth chains actually strand memory. The root pre-size heuristic (factor 3) can be tuned independently.Note heap staging only removes the stranding (factor 1); factors 2–4 (three variant copies, root pre-size, live IR volume) are untouched by it.
Other directions
Allocatorreset between top-level statements, bulk-copying finished sequences into the document arena — removes stranding without system-allocator traffic.FormatElement, emit fewer elements.Measurements:
tasks/track_memory_allocations(snapshot = Linux x64; stage split = macOS aarch64), bench-formatter README (2026-07). Investigation done with Claude.