Skip to content

formatter: IR build allocates 45-80x the source size in the arena (525 MB for antd.js) #24558

Description

@Boshen

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 buildformat_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

  1. 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.
  2. 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.
  3. 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).
  4. 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).
  5. 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.

Metadata

Metadata

Assignees

Labels

A-formatterArea - FormatterC-performanceCategory - Solution not expected to change functional behavior, only performance

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions