Skip to content

react-compiler: stop three passes from using memory quadratic in the size of a component - #42394

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/14592167/rc-quadratic-memory
Sep 12, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/14592167/rc-quadratic-memory

Conversation

@robobun

@robobun robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --react-compiler uses memory quadratic in the size of one loop-free component. A || chain of 400 terms takes 979 MB, an array pattern of 300 elements with defaults 1 GB. 800 terms or 500 elements are OOM-killed at 3 GB. A plain build takes 14 MB.
  • Three places keep one copy of their work per basic block or nesting level. InferMutationAliasingEffects keeps the incoming state of every block (infer_mutation_aliasing_effects.rs:196), at 16 bytes per identifier of the function. Codegen deep-clones the instructions of each nested SequenceExpression (codegen.rs:1735). build_reverse_graph (hir/dominator.rs:163) rebuilds the hash index of an IdMap on every remove.

Fix

  • A block that no back edge reaches is never queued twice. The fixpoint moves its state and keeps no copy.
  • Codegen generates the instructions of a sequence in place. build_reverse_graph uses swap_remove, because nothing reads the map afterwards.
  • The output is byte-identical. After: 400 terms 49 MB, 800 terms 78 MB, 300 elements 84 MB, 500 elements 194 MB.
  • Verified: a new peak-RSS test in test/bundler/transpiler/react-compiler.test.ts (fails on 1.4.2). Also all of react-compiler.test.ts and react-compiler-fixtures.test.ts.

Background

  • InferMutationAliasingEffects is a forward dataflow pass. It sweeps the blocks in order until none is queued. queue() merges a new state into the one the block last saw.
  • HirVec, IndexMap and IdMap allocate in the per-file AST arena. Its deallocate is a no-op, so transient copies stay resident.
  • ValidateHooksUsage, ValidateNoSetStateInRender and InferReactivePlaces each build the post-dominator graph.
Notes

Why a block that no back edge reaches is never queued twice. A block is queued only when a predecessor is processed, and a sweep processes blocks in func.body.blocks order. If block X is queued after its own turn, the predecessor P that queued it was processed after X. Either P is at the same or a later position, so P -> X is an edge to the same or an earlier block and X is such a target itself. Or P is earlier and ran in a later sweep, so P was itself processed after its first turn and the same argument applies to P, and X is reachable from P. blocks_reachable_from_back_edges computes that set from the same successor function the fixpoint uses, and it does not assume the blocks are in reverse postorder.

Where the memory went, from a breakpoint on every pass entry that reads VmRSS (release build with symbols):

  • 400 terms: 60 MB before InferMutationAliasingEffects, 289 MB after it, 172 MB at codegen entry, peak 959 MB in codegen.
  • 200 elements: 45 MB before InferMutationAliasingEffects, 389 MB after it. 7179 identifiers and about 400 blocks at 100 elements.
  • 800 terms after the first two fixes: 494 MB, of which about 135 MB each in ValidateHooksUsage, ValidateNoSetStateInRender and InferReactivePlaces. 3200 blocks times a 40 KB index per remove.

Peak RSS, 1.4.2 then this branch:

input 1.4.2 this branch
100 terms 80 MB 26 MB
400 terms 959 MB 49 MB
800 terms killed at 3 GB 78 MB
100 elements 77 MB 31 MB
300 elements 1064 MB 84 MB
500 elements killed at 3 GB 194 MB
2000 statements + 400 ifs 1569 MB 226 MB

What is left at 500 elements is EnterSSA (115 MB): it caches a definition in every block between a use and its definition, as upstream does. InferReactivePlaces is still quadratic in time, because post_dominators_of walks every ancestor of every block, also as upstream does.

The test uses 100 terms and 120 elements on debug and ASAN builds, because a debug build overflows its stack in lower_logical at about 150 terms and is 20 times slower. It disables the ASAN quarantine for the child, like expectRssDeltaBelow in harness.ts. Measured above an empty build on a debug build: 25 MB and 28 MB with the fix, 112 MB and about 125 MB without.

codegen_for_init has the same clone, but it is not nested, so it is linear. It is not changed here.

…size of a component

A `||` chain of 400 terms took 979 MB to compile and an array pattern of
300 elements with defaults took 1 GB. 800 terms or 500 elements ran out
of memory at 3 GB. A plain build takes 14 MB. Three places kept one copy
of their work per basic block, or per nesting level of a value.

InferMutationAliasingEffects kept the incoming state of every block it
processed, and a state has one cell per identifier of the whole
function. The fixpoint only reads a state back when the block is queued
again, and that needs a back edge. A block that no back edge reaches is
processed once, so its state is now moved into the block and dropped.

Codegen deep-cloned the instructions of a `SequenceExpression` to wrap
them as statements. A `||` chain nests one sequence per term, so each
level cloned everything below it, and the clones live in the AST arena,
which never frees. It now generates the instructions in place.

`build_reverse_graph` took every node out of an `IdMap` with the
order-preserving `remove`, which shifts the rest and rebuilds the hash
index in the arena on every call. The map is not read afterwards, so it
uses `swap_remove`. Three passes build that graph.

After: 400 terms take 49 MB, 800 take 78 MB, 300 elements take 84 MB and
500 take 194 MB.
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 9 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 72b82310-a9a5-4f08-9133-bfb023059832

📥 Commits

Reviewing files that changed from the base of the PR and between 471b586 and 74b12a8.

📒 Files selected for processing (5)
  • src/react_compiler/codegen.rs
  • src/react_compiler/collections.rs
  • src/react_compiler/hir/dominator.rs
  • src/react_compiler/inference/infer_mutation_aliasing_effects.rs
  • test/bundler/transpiler/react-compiler.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on release 1.4.2 and on main 4b5862f with the generators in the PR notes: a || chain of 400 terms peaks at 959 MB, an array pattern of 300 elements with defaults at 1064 MB, where a plain build takes 16 MB. A breakpoint on each pass entry that reads VmRSS attributes the memory to InferMutationAliasingEffects, codegen and the post-dominator graph.

The new peak-RSS test in test/bundler/transpiler/react-compiler.test.ts fails on 1.4.2 (915 MB above an empty build, bound 300 MB) and on a debug build without the fix (112 MB, bound 70 MB). It passes on release and debug builds of this branch.

PR: #42394

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The fixpoint-state-skipping invariant in infer_mutation_aliasing_effects.rs is subtle enough (inductive argument over sweep order) that a human familiar with this pass should confirm it.

What was reviewed:

  • codegen.rs: verified codegen_block_no_reset on a pure-Instruction vec is exactly the new inline loop — the other match arms were unreachable.
  • dominator.rs: raw_nodes is not read after the drain loop, so swap_remove losing order is safe.
  • blocks_reachable_from_back_edges: uses the same terminal_successors as the fixpoint's queue, and the queue helper is the only reader of states_by_block; walked the "P processed after X ⇒ X ∈ revisitable" induction and it holds for the func.body.blocks-order sweep.
  • Test: maxRSS is bytes in Bun (harness guards this), bounds branch on isDebug || isASAN well below the cited unfixed numbers, pipes drained concurrently, bunEnv spread.
Extended reasoning...

Overview

This PR removes three quadratic-memory hotspots in src/react_compiler/: (1) the InferMutationAliasingEffects fixpoint no longer stores a cloned incoming state for blocks that provably run once, gated by a new blocks_reachable_from_back_edges helper; (2) SequenceExpression codegen iterates instructions directly instead of cloning them into a ReactiveStatement vec and calling codegen_block_no_reset; (3) build_reverse_graph uses a new IdMap::swap_remove instead of order-preserving remove. A peak-RSS regression test is added to react-compiler.test.ts.

Security risks

None. This is a compiler-internal memory optimization with no user-controlled input reaching new parsing, allocation-size arithmetic, or filesystem/network paths. The test spawns bun build on locally-generated fixtures in a tempDir.

Level of scrutiny

Medium-high. The React compiler produces output that ships to user applications, so a mis-computed fixpoint (skipping a state merge that would have changed the result) would silently produce wrong memoization. The correctness of change (1) rests on an inductive argument: a block not in the back-edge-reachable set is queued at most once, so its states_by_block entry is never read back by queue(). I traced this — states_by_block is read only inside queue(), the sweep order is the fixed func.body.blocks.keys() order, and the reachability set is closed under the same terminal_successors relation the fixpoint uses to queue — so if any predecessor of X can run after X's turn, X lands in the set. The argument is sound but non-obvious, and the PR author's own notes call out that it does not assume RPO ordering; a maintainer who owns this pass should sanity-check that no other code path (e.g., exception/fallthrough edges outside terminal_successors) ever queues a block.

Other factors

Changes (2) and (3) are mechanical: codegen_block_no_reset on a vec of only ReactiveStatement::Instruction is byte-for-byte the new loop (the other arms were dead for that input), and nothing reads raw_nodes after the postorder drain so swap_remove order loss is inert. The new test follows harness conventions cleanly (tempDir, bunEnv spread, concurrent pipe drain, stderr-before-exitCode, RSS bound branched on isDebug || isASAN and set well below the documented unfixed numbers, ASAN quarantine disabled to match expectRssDeltaBelow). The PR states react-compiler-fixtures.test.ts still passes, which is the byte-identical-output evidence; that plus the fixpoint invariant are the two things a human should confirm.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:06 PM PT - Sep 11th, 2026

✅ @robobun, your commit 74b12a8f681d6043a0a4f31ceb07784183a67216 passed in Build #114483! 🎉


🧪   To try this PR locally:

bunx bun-pr 42394

That installs a local version of the PR into your bun-42394 executable, so you can run:

bun-42394 --bun

@Jarred-Sumner
Jarred-Sumner merged commit 2da1e61 into main Sep 12, 2026
11 of 12 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/14592167/rc-quadratic-memory branch September 12, 2026 03:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants