From cfa65494b1a2bf4ea1ea2462ddbaad552ee15e89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:10:21 +0000 Subject: [PATCH 1/6] react-compiler: do not make a phi and an identifier at a join that a value only passes EnterSSA made a phi, an identifier and a defs entry in every block between a use and its definition. EliminateRedundantPhi drops the phi of a join whose predecessors all have the same identifier, but the identifier stays, and later passes size their tables by the number of identifiers. n values that are live across n joins made n^2 of each. A lookup now first walks back without making anything. A join whose predecessors all reach the same definition takes that identifier. Every other identifier is made in the same order as before, so the output does not change. Only the blocks through which a later lookup can enter the walked region get a defs entry. --- src/react_compiler/ssa/enter_ssa.rs | 195 ++++++++++++++++++ .../bundler/transpiler/react-compiler.test.ts | 64 ++++++ 2 files changed, 259 insertions(+) diff --git a/src/react_compiler/ssa/enter_ssa.rs b/src/react_compiler/ssa/enter_ssa.rs index 82c526d36c08..2dd6b9744123 100644 --- a/src/react_compiler/ssa/enter_ssa.rs +++ b/src/react_compiler/ssa/enter_ssa.rs @@ -20,6 +20,35 @@ struct State { incomplete_phis: Vec, } +/// Not in upstream. The identifier that `get_id_at` returns for a block, known +/// before `get_id_at` makes anything. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Reaching { + /// An identifier that is in `defs` already. + Def(IdentifierId), + /// The identifier that `get_id_at` makes at this block: a phi or an incomplete phi. + MadeAt(BlockId), + /// The old identifier: no block on the way to a block without predecessors defines it. + Unknown, +} + +/// Not in upstream. What one lookup knows about a block that it passed. +#[derive(Clone, Copy)] +struct Passed { + /// `SSABuilder::lookup` of that lookup. Every other lookup ignores the rest. + lookup: u64, + reaching: Reaching, + /// How many successors of the block the lookup came from. + came_from: u32, +} + +/// Not in upstream. A block whose predecessors `SSABuilder::reaching` still asks. +struct Asked { + block_id: BlockId, + next_pred: usize, + reaching: Option, +} + struct SSABuilder { /// Indexed by `BlockId.0`. states: Vec>, @@ -33,13 +62,29 @@ struct SSABuilder { /// Indexed by `BlockId.0`. pending_phis: Vec>, processed_functions: Vec, + /// Not in upstream. Indexed by `BlockId.0`: how many successors the block has. + successors: Vec, + /// Not in upstream. Indexed by `BlockId.0`. + passed: Vec, + /// Not in upstream. The blocks that the current lookup passed. + passed_blocks: Vec, + /// Not in upstream. Empty between two calls of `reaching`, kept for its capacity. + asked: Vec, + /// Not in upstream. Counts the lookups that start outside `get_id_at`. Each + /// is for one identifier, and nothing else adds that identifier to `defs` + /// while the lookup runs, so what `passed` says stays true until it ends. + lookup: u64, } impl SSABuilder { fn new(blocks: &IndexMap, num_blocks: usize) -> Self { let mut block_preds: Vec> = vec![Vec::new(); num_blocks]; + let mut successors: Vec = vec![0; num_blocks]; for (id, block) in blocks { block_preds[id.0 as usize] = block.preds.iter().copied().collect(); + for pred in &block.preds { + successors[pred.0 as usize] += 1; + } } let mut states = Vec::with_capacity(num_blocks); states.resize_with(num_blocks, || None); @@ -52,12 +97,27 @@ impl SSABuilder { context: HashSet::new(), pending_phis: vec![Vec::new(); num_blocks], processed_functions: Vec::new(), + successors, + passed: vec![ + Passed { + lookup: 0, + reaching: Reaching::MadeAt(BlockId(0)), + came_from: 0, + }; + num_blocks + ], + passed_blocks: Vec::new(), + asked: Vec::new(), + lookup: 1, } } fn define_function(&mut self, func: &HirFunction) { for (id, block) in &func.body.blocks { self.block_preds[id.0 as usize] = block.preds.iter().copied().collect(); + for pred in &block.preds { + self.successors[pred.0 as usize] += 1; + } } } @@ -135,6 +195,7 @@ impl SSABuilder { fn get_place(&mut self, old_place: &Place, env: &mut Environment) -> Place { let current_id = self.current.expect("must be in a block"); let new_id = self.get_id_at(old_place, current_id, env); + self.end_lookup(old_place.identifier); Place { identifier: new_id, effect: old_place.effect, @@ -192,6 +253,26 @@ impl SSABuilder { return new_id; } + // Not in upstream, which makes a phi and an identifier at every join that a + // lookup passes. EliminateRedundantPhi drops the phi of a join whose + // predecessors all have the same identifier, but the identifier stays: n + // values that are live across n joins make n^2 of them. Such a join takes + // the identifier of its predecessors here. Every identifier that is still + // made is made in the order of upstream. + let new_id = match self.reaching(old_place.identifier, block_id) { + Reaching::Def(new_id) => Some(new_id), + Reaching::MadeAt(at) if at != block_id => Some(self.get_id_at(old_place, at, env)), + Reaching::MadeAt(_) | Reaching::Unknown => None, + }; + if let Some(new_id) = new_id { + self.states[block_id.0 as usize] + .as_mut() + .unwrap() + .defs + .insert(old_place.identifier, new_id); + return new_id; + } + let new_id = self.make_id(old_place.identifier, env); self.states[block_id.0 as usize] .as_mut() @@ -208,6 +289,119 @@ impl SSABuilder { new_id } + /// Not in upstream. A block with the identifier in `defs` is `Def`, a block + /// without predecessors is `Unknown`, and an unsealed block is `MadeAt` + /// itself. Every other block has the `Reaching` of its predecessors when + /// they all have the same one, and is `MadeAt` itself, the place of a phi, + /// when they do not. A block that is asked again while it waits for its + /// predecessors closes a loop. It answers `MadeAt` itself, as upstream + /// answers with its phi. A join of `Unknown` stays a phi too, because + /// `unmark_unknown` tells the old identifier from a phi of it. + /// + /// This does not recurse: a lookup can pass every block of the function. + fn reaching(&mut self, old_id: IdentifierId, block_id: BlockId) -> Reaching { + let mut asked = std::mem::take(&mut self.asked); + let mut block_id = block_id; + let reaching = loop { + let index = block_id.0 as usize; + let came_from = u32::from(!asked.is_empty()); + let mut found = if let Some(&new_id) = self.states[index] + .as_ref() + .and_then(|state| state.defs.get(old_id)) + { + Some(if new_id == old_id { + Reaching::Unknown + } else { + Reaching::Def(new_id) + }) + } else if self.passed[index].lookup == self.lookup { + self.passed[index].came_from += came_from; + Some(self.passed[index].reaching) + } else if self.block_preds[index].is_empty() { + Some(Reaching::Unknown) + } else if self.unsealed_preds[index].unwrap_or(0) > 0 { + Some(Reaching::MadeAt(block_id)) + } else { + self.passed[index] = Passed { + lookup: self.lookup, + reaching: Reaching::MadeAt(block_id), + came_from, + }; + self.passed_blocks.push(block_id); + asked.push(Asked { + block_id, + next_pred: 0, + reaching: None, + }); + None + }; + // Give what was found to the block that asked, then ask its next + // predecessor, or find out what the block itself is. + let next_pred = loop { + let Some(top) = asked.last_mut() else { + break None; + }; + if let Some(found) = found.take() { + top.reaching = Some(match top.reaching { + Some(reaching) if reaching != found => Reaching::MadeAt(top.block_id), + _ => found, + }); + } + let preds = &self.block_preds[top.block_id.0 as usize]; + if let Some(&pred) = preds.get(top.next_pred) { + top.next_pred += 1; + break Some(pred); + } + let mut reaching = top.reaching.expect("a block that asks has predecessors"); + if reaching == Reaching::Unknown && preds.len() > 1 { + reaching = Reaching::MadeAt(top.block_id); + } + self.passed[top.block_id.0 as usize].reaching = reaching; + asked.pop(); + found = Some(reaching); + }; + match next_pred { + Some(pred) => block_id = pred, + None => break found.expect("the first block was found or it asked"), + } + }; + self.asked = asked; + reaching + } + + /// Not in upstream, which adds the identifier to `defs` of every block that + /// a lookup passes. A later lookup comes to a passed block only from a + /// successor that this lookup did not come from, so only a block with such + /// a successor gets the identifier. + fn end_lookup(&mut self, old_id: IdentifierId) { + for i in 0..self.passed_blocks.len() { + let index = self.passed_blocks[i].0 as usize; + let Passed { + reaching, + came_from, + .. + } = self.passed[index]; + if came_from == self.successors[index] { + continue; + } + let new_id = match reaching { + Reaching::Def(new_id) => Some(new_id), + Reaching::MadeAt(at) => self.states[at.0 as usize] + .as_ref() + .and_then(|state| state.defs.get(old_id)) + .copied(), + Reaching::Unknown => Some(old_id), + }; + if let (Some(new_id), Some(state)) = (new_id, self.states[index].as_mut()) { + if !state.defs.contains_key(old_id) { + state.defs.insert(old_id, new_id); + } + } + } + self.passed_blocks.clear(); + self.lookup += 1; + } + fn add_phi( &mut self, block_id: BlockId, @@ -248,6 +442,7 @@ impl SSABuilder { ); for phi in &incomplete_phis { self.add_phi(block_id, &phi.old_place, &phi.new_place, env); + self.end_lookup(phi.old_place.identifier); } } diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index a23e6050b820..c6c0ac496a39 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -3161,6 +3161,70 @@ test("react-compiler keeps one copy of each dependency of a phi", async () => { expect(ladder.peakMB - empty.peakMB).toBeLessThan(100); }); +// EnterSSA finds the definition of a value from its use. At every join on the +// way it made a phi, an identifier and four `defs` entries. EliminateRedundantPhi +// drops the phi of a join that the value only passes, but the identifier stays, +// and later passes size their tables by the number of identifiers. So n values +// that are live across n joins cost n * n of each: an element with 1000 +// conditional children took 5 seconds and 494 MB, and one with 4000 took 7 GB. +test("react-compiler memory does not grow with the values times the joins they are live across", async () => { + // A debug build is 20 times slower. + const small = isDebug || isASAN; + const values = Array.from({ length: small ? 600 : 4000 }, (_, i) => `{${i}}`).join(""); + const joins = Array.from({ length: small ? 50 : 100 }, (_, i) => `{p.c${i} ? 1 : 2}`).join(""); + using dir = tempDir("react-compiler-live-across-joins", { + // The children are evaluated in order and the element is made after the + // last one. Only in across.jsx is each value live across every join. + "before.jsx": `export default function App(p) { return
${joins}${values}
; }`, + "across.jsx": `export default function App(p) { return
${values}${joins}
; }`, + // On Linux the peak that a parent reads for a child starts at the size of + // the parent, and the test runner of a debug build is larger than either + // build. VmHWM starts again at exec. + "peak.js": ` + import { readFileSync } from "node:fs"; + const result = await Bun.build({ + entrypoints: [process.argv[2]], + target: "browser", + external: ["*"], + reactCompiler: true, + }); + const output = await result.outputs[0].text(); + const peakKB = + process.platform === "linux" + ? Number(/^VmHWM:\\s*(\\d+) kB$/m.exec(readFileSync("/proc/self/status", "utf8"))[1]) + : process.resourceUsage().maxRSS; + console.log(JSON.stringify({ memoized: /\\b_c\\(\\d+\\)/.test(output), peakMB: peakKB / 1024 })); + `, + }); + + const peakMB = async (entry: string) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "peak.js", entry], + env: { + ...bunEnv, + // ASAN's quarantine keeps freed blocks resident, which hides the difference. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0", "thread_local_quarantine_size_kb=0"] + .filter(Boolean) + .join(":"), + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { memoized, peakMB } = JSON.parse(stdout); + expect(memoized).toBe(true); + expect(exitCode).toBe(0); + return peakMB as number; + }; + + const [before, across] = await Promise.all([peakMB("before.jsx"), peakMB("across.jsx")]); + // Without the fix across.jsx takes 40 MB more than before.jsx in a debug + // build and 320 MB more in a release build. With it they take the same. + expect(across - before).toBeLessThan(small ? 20 : 100); +}); + // validate_locals_not_reassigned_after_render (src/react_compiler/validation) // records the locals a component's closures capture while walking the // component body, and reports a nested function that assigns to one of them, From f0853a680dcb76979d339f9eabd7bbae9bf0b373 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:12:05 +0000 Subject: [PATCH 2/6] react-compiler: uncount the entry of a nested function as a successor in EnterSSA define_function counts the entry block of a nested function as a successor of the block that holds the function expression. That edge goes away once the function is processed, but the count stayed. So the block never counted as fully walked, and every lookup that passed it left a defs entry there. Also assert that a lookup ends before the next one starts, say in reaching that it follows the cases of get_id_at, describe the 'Not in upstream' marker in DESIGN.md, and measure a second pair in the release-size test: locals that are a phi before the joins. --- src/react_compiler/DESIGN.md | 5 ++ src/react_compiler/ssa/enter_ssa.rs | 16 +++++- .../bundler/transpiler/react-compiler.test.ts | 57 +++++++++++++------ 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/react_compiler/DESIGN.md b/src/react_compiler/DESIGN.md index 285c66f33d32..7deb281bf823 100644 --- a/src/react_compiler/DESIGN.md +++ b/src/react_compiler/DESIGN.md @@ -66,6 +66,11 @@ Keep the diff between upstream and the port as small as the type substitution allows — the `/sync-react-compiler` skill re-ports upstream changes hunk by hunk, so gratuitous restructuring makes that harder. +Where a ported file has to differ from upstream (a bug or a cost that upstream +has too), the code that differs starts with a `Not in upstream` comment that +says why. Search the file for that marker before you apply an upstream hunk +to it. + ### Type mapping (input: lowering) | upstream `react_compiler_ast` | `bun_ast` | diff --git a/src/react_compiler/ssa/enter_ssa.rs b/src/react_compiler/ssa/enter_ssa.rs index 2dd6b9744123..17739601df76 100644 --- a/src/react_compiler/ssa/enter_ssa.rs +++ b/src/react_compiler/ssa/enter_ssa.rs @@ -74,6 +74,8 @@ struct SSABuilder { /// is for one identifier, and nothing else adds that identifier to `defs` /// while the lookup runs, so what `passed` says stays true until it ends. lookup: u64, + /// Not in upstream. The identifier of the current lookup, once `reaching` ran for it. + looked_up: Option, } impl SSABuilder { @@ -109,6 +111,7 @@ impl SSABuilder { passed_blocks: Vec::new(), asked: Vec::new(), lookup: 1, + looked_up: None, } } @@ -298,8 +301,15 @@ impl SSABuilder { /// answers with its phi. A join of `Unknown` stays a phi too, because /// `unmark_unknown` tells the old identifier from a phi of it. /// - /// This does not recurse: a lookup can pass every block of the function. + /// These are the cases of `get_id_at`. A change to one needs the same change + /// in the other. This does not recurse: a lookup can pass every block of the + /// function. fn reaching(&mut self, old_id: IdentifierId, block_id: BlockId) -> Reaching { + debug_assert!( + self.looked_up.is_none_or(|id| id == old_id), + "the lookup before this one did not call end_lookup" + ); + self.looked_up = Some(old_id); let mut asked = std::mem::take(&mut self.asked); let mut block_id = block_id; let reaching = loop { @@ -374,6 +384,8 @@ impl SSABuilder { /// successor that this lookup did not come from, so only a block with such /// a successor gets the identifier. fn end_lookup(&mut self, old_id: IdentifierId) { + debug_assert!(self.looked_up.is_none_or(|id| id == old_id)); + self.looked_up = None; for i in 0..self.passed_blocks.len() { let index = self.passed_blocks[i].0 as usize; let Passed { @@ -657,6 +669,8 @@ fn enter_ssa_impl( .preds .clear(); builder.block_preds[inner_entry.0 as usize] = Vec::new(); + // Not in upstream: `define_function` counted the entry block as a successor. + builder.successors[block_id.0 as usize] -= 1; } } diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index c6c0ac496a39..cdc2c6d9e6df 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -3170,16 +3170,17 @@ test("react-compiler keeps one copy of each dependency of a phi", async () => { test("react-compiler memory does not grow with the values times the joins they are live across", async () => { // A debug build is 20 times slower. const small = isDebug || isASAN; - const values = Array.from({ length: small ? 600 : 4000 }, (_, i) => `{${i}}`).join(""); const joins = Array.from({ length: small ? 50 : 100 }, (_, i) => `{p.c${i} ? 1 : 2}`).join(""); - using dir = tempDir("react-compiler-live-across-joins", { - // The children are evaluated in order and the element is made after the - // last one. Only in across.jsx is each value live across every join. - "before.jsx": `export default function App(p) { return
${joins}${values}
; }`, - "across.jsx": `export default function App(p) { return
${values}${joins}
; }`, - // On Linux the peak that a parent reads for a child starts at the size of - // the parent, and the test runner of a debug build is larger than either - // build. VmHWM starts again at exec. + // The children of an element are evaluated in order and the element is made + // after the last one. So a literal that is ahead of the joins is live across + // each of them, and one that is after them is live across none. + const literals = Array.from({ length: small ? 600 : 4000 }, (_, i) => `{${i}}`).join(""); + const files: Record = { + "literals-before.jsx": `export default function App(p) { return
${joins}${literals}
; }`, + "literals-across.jsx": `export default function App(p) { return
${literals}${joins}
; }`, + // On Linux ru_maxrss survives exec. Neither the parent nor the child reads + // a peak below the size of the parent at spawn, and the test runner of a + // debug build is larger than either build. VmHWM starts again at exec. "peak.js": ` import { readFileSync } from "node:fs"; const result = await Bun.build({ @@ -3195,9 +3196,23 @@ test("react-compiler memory does not grow with the values times the joins they a : process.resourceUsage().maxRSS; console.log(JSON.stringify({ memoized: /\\b_c\\(\\d+\\)/.test(output), peakMB: peakKB / 1024 })); `, - }); + }; + if (!small) { + // A local that both arms of an `if` assign is a phi after the `if`, and a + // read of it after the joins finds that phi through them. A debug build + // has no time for this pair: a local costs it twice what a literal does. + const locals = Array.from({ length: 4000 }, (_, i) => `l${i}`); + const assign = `let ${locals.join(", ")}; + if (p.x) { ${locals.map((local, i) => `${local} = ${i};`).join(" ")} } + else { ${locals.map((local, i) => `${local} = ${i + 1};`).join(" ")} }`; + const joined = `const joined = ${joins};`; + const read = `return
{joined}${locals.map(local => `{${local}}`).join("")}
;`; + files["locals-before.jsx"] = `export default function App(p) { ${joined} ${assign} ${read} }`; + files["locals-across.jsx"] = `export default function App(p) { ${assign} ${joined} ${read} }`; + } + using dir = tempDir("react-compiler-live-across-joins", files); - const peakMB = async (entry: string) => { + const measure = async (entry: string) => { await using proc = Bun.spawn({ cmd: [bunExe(), "peak.js", entry], env: { @@ -3213,16 +3228,22 @@ test("react-compiler memory does not grow with the values times the joins they a }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - const { memoized, peakMB } = JSON.parse(stdout); - expect(memoized).toBe(true); + const result: { memoized: boolean; peakMB: number } = JSON.parse(stdout); + expect(result.memoized).toBe(true); expect(exitCode).toBe(0); - return peakMB as number; + return result.peakMB; }; - const [before, across] = await Promise.all([peakMB("before.jsx"), peakMB("across.jsx")]); - // Without the fix across.jsx takes 40 MB more than before.jsx in a debug - // build and 320 MB more in a release build. With it they take the same. - expect(across - before).toBeLessThan(small ? 20 : 100); + const over = async (kind: string) => { + const [before, across] = await Promise.all([measure(`${kind}-before.jsx`), measure(`${kind}-across.jsx`)]); + return across - before; + }; + const [literalsOver, localsOver] = await Promise.all([over("literals"), small ? 0 : over("locals")]); + // Without the fix the second file of a pair takes 40 MB more than the first + // in a debug build and 300 MB more in a release build. With it they take + // the same. + expect(literalsOver).toBeLessThan(small ? 20 : 100); + expect(localsOver).toBeLessThan(small ? 20 : 100); }); // validate_locals_not_reassigned_after_render (src/react_compiler/validation) From 7b7c3d2245767091af63dbe7d96ab081b7ce217f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:51:31 +0000 Subject: [PATCH 3/6] test: report the release-only react-compiler memory cases as skipped, and cover the successor count The debug lane asserted a constant for the locals pair. Each case is now its own test, and the two that a debug build has no time for are skipped there. The third case fails when EnterSSA still counts the entry block of a nested function as a successor: its twin has the same functions after the joins, so the copy of the temporaries that codegen makes for each nested function cancels out. --- .../bundler/transpiler/react-compiler.test.ts | 149 ++++++++++-------- 1 file changed, 85 insertions(+), 64 deletions(-) diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index cdc2c6d9e6df..5d4af653f4d3 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -3167,83 +3167,104 @@ test("react-compiler keeps one copy of each dependency of a phi", async () => { // and later passes size their tables by the number of identifiers. So n values // that are live across n joins cost n * n of each: an element with 1000 // conditional children took 5 seconds and 494 MB, and one with 4000 took 7 GB. -test("react-compiler memory does not grow with the values times the joins they are live across", async () => { +describe.concurrent("react-compiler memory does not grow with the values times the joins they are live across", () => { // A debug build is 20 times slower. const small = isDebug || isASAN; - const joins = Array.from({ length: small ? 50 : 100 }, (_, i) => `{p.c${i} ? 1 : 2}`).join(""); + const children = (count: number, child: (i: number) => string) => + Array.from({ length: count }, (_, i) => child(i)).join(""); // The children of an element are evaluated in order and the element is made // after the last one. So a literal that is ahead of the joins is live across // each of them, and one that is after them is live across none. - const literals = Array.from({ length: small ? 600 : 4000 }, (_, i) => `{${i}}`).join(""); - const files: Record = { - "literals-before.jsx": `export default function App(p) { return
${joins}${literals}
; }`, - "literals-across.jsx": `export default function App(p) { return
${literals}${joins}
; }`, - // On Linux ru_maxrss survives exec. Neither the parent nor the child reads - // a peak below the size of the parent at spawn, and the test runner of a - // debug build is larger than either build. VmHWM starts again at exec. - "peak.js": ` - import { readFileSync } from "node:fs"; - const result = await Bun.build({ - entrypoints: [process.argv[2]], - target: "browser", - external: ["*"], - reactCompiler: true, + const literals = children(small ? 600 : 4000, i => `{${i}}`); + + // How much more the build of `body` takes than the build of `twin`, in MB. + // The twin has the same statements in another order, so this leaves out + // what the other passes need. + const overItsTwin = async (twin: string, body: string) => { + using dir = tempDir("react-compiler-live-across-joins", { + "twin.jsx": `export default function App(p) { ${twin} }`, + "body.jsx": `export default function App(p) { ${body} }`, + // On Linux ru_maxrss survives exec. Neither the parent nor the child + // reads a peak below the size of the parent at spawn, and the test runner + // of a debug build is larger than either build. VmHWM starts again at exec. + "peak.js": ` + import { readFileSync } from "node:fs"; + const result = await Bun.build({ + entrypoints: [process.argv[2]], + target: "browser", + external: ["*"], + reactCompiler: true, + }); + const output = await result.outputs[0].text(); + const peakKB = + process.platform === "linux" + ? Number(/^VmHWM:\\s*(\\d+) kB$/m.exec(readFileSync("/proc/self/status", "utf8"))[1]) + : process.resourceUsage().maxRSS; + console.log(JSON.stringify({ memoized: /\\b_c\\(\\d+\\)/.test(output), peakMB: peakKB / 1024 })); + `, + }); + const measure = async (entry: string) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "peak.js", entry], + env: { + ...bunEnv, + // ASAN's quarantine keeps freed blocks resident, which hides the difference. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0", "thread_local_quarantine_size_kb=0"] + .filter(Boolean) + .join(":"), + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", }); - const output = await result.outputs[0].text(); - const peakKB = - process.platform === "linux" - ? Number(/^VmHWM:\\s*(\\d+) kB$/m.exec(readFileSync("/proc/self/status", "utf8"))[1]) - : process.resourceUsage().maxRSS; - console.log(JSON.stringify({ memoized: /\\b_c\\(\\d+\\)/.test(output), peakMB: peakKB / 1024 })); - `, + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const result: { memoized: boolean; peakMB: number } = JSON.parse(stdout); + expect(result.memoized).toBe(true); + expect(exitCode).toBe(0); + return result.peakMB; + }; + const [twinMB, bodyMB] = await Promise.all([measure("twin.jsx"), measure("body.jsx")]); + return bodyMB - twinMB; }; - if (!small) { - // A local that both arms of an `if` assign is a phi after the `if`, and a - // read of it after the joins finds that phi through them. A debug build - // has no time for this pair: a local costs it twice what a literal does. + + test("for a value that a block defines", async () => { + const joins = children(small ? 50 : 100, i => `{p.c${i} ? 1 : 2}`); + // Without the fix: 40 MB in a debug build, 335 MB in a release build. + expect( + await overItsTwin(`return
${joins}${literals}
;`, `return
${literals}${joins}
;`), + ).toBeLessThan(small ? 20 : 100); + }); + + // A local that both arms of an `if` assign is a phi after the `if`, and a + // read of it after the joins finds that phi through them. A debug build has + // no time for this: a local costs it twice what a literal does. + test.skipIf(small)("for a value that is the phi of an earlier join", async () => { const locals = Array.from({ length: 4000 }, (_, i) => `l${i}`); const assign = `let ${locals.join(", ")}; if (p.x) { ${locals.map((local, i) => `${local} = ${i};`).join(" ")} } else { ${locals.map((local, i) => `${local} = ${i + 1};`).join(" ")} }`; - const joined = `const joined = ${joins};`; + const joined = `const joined = ${children(100, i => `{p.c${i} ? 1 : 2}`)};`; const read = `return
{joined}${locals.map(local => `{${local}}`).join("")}
;`; - files["locals-before.jsx"] = `export default function App(p) { ${joined} ${assign} ${read} }`; - files["locals-across.jsx"] = `export default function App(p) { ${assign} ${joined} ${read} }`; - } - using dir = tempDir("react-compiler-live-across-joins", files); - - const measure = async (entry: string) => { - await using proc = Bun.spawn({ - cmd: [bunExe(), "peak.js", entry], - env: { - ...bunEnv, - // ASAN's quarantine keeps freed blocks resident, which hides the difference. - ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0", "thread_local_quarantine_size_kb=0"] - .filter(Boolean) - .join(":"), - }, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - const result: { memoized: boolean; peakMB: number } = JSON.parse(stdout); - expect(result.memoized).toBe(true); - expect(exitCode).toBe(0); - return result.peakMB; - }; + // Without the fix: 300 MB. + expect(await overItsTwin(`${joined} ${assign} ${read}`, `${assign} ${joined} ${read}`)).toBeLessThan(100); + }); - const over = async (kind: string) => { - const [before, across] = await Promise.all([measure(`${kind}-before.jsx`), measure(`${kind}-across.jsx`)]); - return across - before; - }; - const [literalsOver, localsOver] = await Promise.all([over("literals"), small ? 0 : over("locals")]); - // Without the fix the second file of a pair takes 40 MB more than the first - // in a debug build and 300 MB more in a release build. With it they take - // the same. - expect(literalsOver).toBeLessThan(small ? 20 : 100); - expect(localsOver).toBeLessThan(small ? 20 : 100); + // While EnterSSA is in a nested function, the entry block of that function is + // a successor of the block that holds the function expression. With that + // successor still counted afterwards, such a block kept a `defs` entry for + // every value that is live across it. Codegen copies its temporaries for + // each nested function, so the twin has the same functions, after the joins. + // An entry is small: it takes this many to see them, which a debug build has + // no time for. + test.skipIf(small)("when a join holds a function expression", async () => { + const inside = children(400, i => `{p.c${i} ? f(${i})} /> : null}`); + const outside = children(400, i => `{p.c${i} ? : null}`) + children(400, i => `{() => f(${i})}`); + // With the successor still counted: 80 MB. + expect( + await overItsTwin(`return
${literals}${outside}
;`, `return
${literals}${inside}
;`), + ).toBeLessThan(40); + }); }); // validate_locals_not_reassigned_after_render (src/react_compiler/validation) From 3ba1647ff81106159f4e27a8a59e2c4d346bcf20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:59:34 +0000 Subject: [PATCH 4/6] react-compiler: keep each new comment in enter_ssa.rs to one line --- src/react_compiler/ssa/enter_ssa.rs | 40 ++++++++--------------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/src/react_compiler/ssa/enter_ssa.rs b/src/react_compiler/ssa/enter_ssa.rs index 17739601df76..52d9d8bc225a 100644 --- a/src/react_compiler/ssa/enter_ssa.rs +++ b/src/react_compiler/ssa/enter_ssa.rs @@ -20,8 +20,7 @@ struct State { incomplete_phis: Vec, } -/// Not in upstream. The identifier that `get_id_at` returns for a block, known -/// before `get_id_at` makes anything. +/// Not in upstream. What `get_id_at` returns for a block, known before it makes anything. #[derive(Clone, Copy, PartialEq, Eq)] enum Reaching { /// An identifier that is in `defs` already. @@ -70,9 +69,7 @@ struct SSABuilder { passed_blocks: Vec, /// Not in upstream. Empty between two calls of `reaching`, kept for its capacity. asked: Vec, - /// Not in upstream. Counts the lookups that start outside `get_id_at`. Each - /// is for one identifier, and nothing else adds that identifier to `defs` - /// while the lookup runs, so what `passed` says stays true until it ends. + /// Not in upstream. Counts the lookups from outside `get_id_at`. Each is for one identifier. lookup: u64, /// Not in upstream. The identifier of the current lookup, once `reaching` ran for it. looked_up: Option, @@ -256,15 +253,11 @@ impl SSABuilder { return new_id; } - // Not in upstream, which makes a phi and an identifier at every join that a - // lookup passes. EliminateRedundantPhi drops the phi of a join whose - // predecessors all have the same identifier, but the identifier stays: n - // values that are live across n joins make n^2 of them. Such a join takes - // the identifier of its predecessors here. Every identifier that is still - // made is made in the order of upstream. + // Not in upstream, which makes a phi here also when all predecessors have one identifier. let new_id = match self.reaching(old_place.identifier, block_id) { Reaching::Def(new_id) => Some(new_id), Reaching::MadeAt(at) if at != block_id => Some(self.get_id_at(old_place, at, env)), + // The upstream code below makes every identifier that is still made, in its order. Reaching::MadeAt(_) | Reaching::Unknown => None, }; if let Some(new_id) = new_id { @@ -292,24 +285,14 @@ impl SSABuilder { new_id } - /// Not in upstream. A block with the identifier in `defs` is `Def`, a block - /// without predecessors is `Unknown`, and an unsealed block is `MadeAt` - /// itself. Every other block has the `Reaching` of its predecessors when - /// they all have the same one, and is `MadeAt` itself, the place of a phi, - /// when they do not. A block that is asked again while it waits for its - /// predecessors closes a loop. It answers `MadeAt` itself, as upstream - /// answers with its phi. A join of `Unknown` stays a phi too, because - /// `unmark_unknown` tells the old identifier from a phi of it. - /// - /// These are the cases of `get_id_at`. A change to one needs the same change - /// in the other. This does not recurse: a lookup can pass every block of the - /// function. + /// Not in upstream. The cases of `get_id_at` without its side effects: change the two together. fn reaching(&mut self, old_id: IdentifierId, block_id: BlockId) -> Reaching { debug_assert!( self.looked_up.is_none_or(|id| id == old_id), "the lookup before this one did not call end_lookup" ); self.looked_up = Some(old_id); + // A work list and no recursion: a lookup can pass every block of the function. let mut asked = std::mem::take(&mut self.asked); let mut block_id = block_id; let reaching = loop { @@ -332,6 +315,7 @@ impl SSABuilder { } else if self.unsealed_preds[index].unwrap_or(0) > 0 { Some(Reaching::MadeAt(block_id)) } else { + // A block that is asked again before it is done closes a loop: it is its phi. self.passed[index] = Passed { lookup: self.lookup, reaching: Reaching::MadeAt(block_id), @@ -345,8 +329,7 @@ impl SSABuilder { }); None }; - // Give what was found to the block that asked, then ask its next - // predecessor, or find out what the block itself is. + // Give `found` to the block that asked, then ask its next predecessor or finish it. let next_pred = loop { let Some(top) = asked.last_mut() else { break None; @@ -363,6 +346,7 @@ impl SSABuilder { break Some(pred); } let mut reaching = top.reaching.expect("a block that asks has predecessors"); + // `unmark_unknown` tells the old identifier from a phi of it: the join stays a phi. if reaching == Reaching::Unknown && preds.len() > 1 { reaching = Reaching::MadeAt(top.block_id); } @@ -379,10 +363,7 @@ impl SSABuilder { reaching } - /// Not in upstream, which adds the identifier to `defs` of every block that - /// a lookup passes. A later lookup comes to a passed block only from a - /// successor that this lookup did not come from, so only a block with such - /// a successor gets the identifier. + /// Not in upstream, which adds the identifier to `defs` of every block that a lookup passes. fn end_lookup(&mut self, old_id: IdentifierId) { debug_assert!(self.looked_up.is_none_or(|id| id == old_id)); self.looked_up = None; @@ -393,6 +374,7 @@ impl SSABuilder { came_from, .. } = self.passed[index]; + // A later lookup comes to this block only from a successor that this one skipped. if came_from == self.successors[index] { continue; } From 2be93217c351e2222ec52e8e50612a2d22240109 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:12:08 +0000 Subject: [PATCH 5/6] ci: retrigger From c9f86bf55b488bdd2e7fd4dc8faa30baf6bbb009 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:19:42 +0000 Subject: [PATCH 6/6] react-compiler: mark the remaining lines in enter_ssa.rs that are not in upstream --- src/react_compiler/ssa/enter_ssa.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/react_compiler/ssa/enter_ssa.rs b/src/react_compiler/ssa/enter_ssa.rs index 52d9d8bc225a..1bb960006c5c 100644 --- a/src/react_compiler/ssa/enter_ssa.rs +++ b/src/react_compiler/ssa/enter_ssa.rs @@ -78,6 +78,7 @@ struct SSABuilder { impl SSABuilder { fn new(blocks: &IndexMap, num_blocks: usize) -> Self { let mut block_preds: Vec> = vec![Vec::new(); num_blocks]; + // Not in upstream: the successor counts that `end_lookup` compares with. let mut successors: Vec = vec![0; num_blocks]; for (id, block) in blocks { block_preds[id.0 as usize] = block.preds.iter().copied().collect(); @@ -96,6 +97,7 @@ impl SSABuilder { context: HashSet::new(), pending_phis: vec![Vec::new(); num_blocks], processed_functions: Vec::new(), + // Not in upstream: the fields from here on. successors, passed: vec![ Passed { @@ -115,6 +117,7 @@ impl SSABuilder { fn define_function(&mut self, func: &HirFunction) { for (id, block) in &func.body.blocks { self.block_preds[id.0 as usize] = block.preds.iter().copied().collect(); + // Not in upstream: the successor counts, as in `new`. for pred in &block.preds { self.successors[pred.0 as usize] += 1; } @@ -195,6 +198,7 @@ impl SSABuilder { fn get_place(&mut self, old_place: &Place, env: &mut Environment) -> Place { let current_id = self.current.expect("must be in a block"); let new_id = self.get_id_at(old_place, current_id, env); + // Not in upstream: this lookup is over. self.end_lookup(old_place.identifier); Place { identifier: new_id, @@ -436,6 +440,7 @@ impl SSABuilder { ); for phi in &incomplete_phis { self.add_phi(block_id, &phi.old_place, &phi.new_place, env); + // Not in upstream: each incomplete phi is a lookup of its own. self.end_lookup(phi.old_place.identifier); } }