Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/react_compiler/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
196 changes: 196 additions & 0 deletions src/react_compiler/ssa/enter_ssa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ struct State {
incomplete_phis: Vec<IncompletePhi>,
}

/// 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.
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<Reaching>,
}

struct SSABuilder {
/// Indexed by `BlockId.0`.
states: Vec<Option<State>>,
Expand All @@ -33,13 +61,30 @@ struct SSABuilder {
/// Indexed by `BlockId.0`.
pending_phis: Vec<Vec<Phi>>,
processed_functions: Vec<FunctionId>,
/// Not in upstream. Indexed by `BlockId.0`: how many successors the block has.
successors: Vec<u32>,
/// Not in upstream. Indexed by `BlockId.0`.
passed: Vec<Passed>,
/// Not in upstream. The blocks that the current lookup passed.
passed_blocks: Vec<BlockId>,
/// Not in upstream. Empty between two calls of `reaching`, kept for its capacity.
asked: Vec<Asked>,
/// 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<IdentifierId>,
}

impl SSABuilder {
fn new(blocks: &IndexMap<BlockId, BasicBlock>, num_blocks: usize) -> Self {
let mut block_preds: Vec<Vec<BlockId>> = vec![Vec::new(); num_blocks];
// Not in upstream: the successor counts that `end_lookup` compares with.
let mut successors: Vec<u32> = vec![0; num_blocks];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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);
Expand All @@ -52,12 +97,30 @@ 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 {
lookup: 0,
reaching: Reaching::MadeAt(BlockId(0)),
came_from: 0,
};
num_blocks
],
passed_blocks: Vec::new(),
asked: Vec::new(),
lookup: 1,
looked_up: None,
}
}

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;
}
}
}

Expand Down Expand Up @@ -135,6 +198,8 @@ 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,
effect: old_place.effect,
Expand Down Expand Up @@ -192,6 +257,22 @@ impl SSABuilder {
return new_id;
}

// 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 {
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()
Expand All @@ -208,6 +289,117 @@ impl SSABuilder {
new_id
}

/// 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 {
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 {
// 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),
came_from,
};
self.passed_blocks.push(block_id);
asked.push(Asked {
block_id,
next_pred: 0,
reaching: None,
});
None
};
// 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;
};
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");
// `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);
}
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.
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 {
reaching,
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;
}
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,
Expand Down Expand Up @@ -248,6 +440,8 @@ 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);
}
}

Expand Down Expand Up @@ -462,6 +656,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;
Comment thread
robobun marked this conversation as resolved.
Resolved
}
}

Expand Down
106 changes: 106 additions & 0 deletions test/bundler/transpiler/react-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3161,6 +3161,112 @@ 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.
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 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 = 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 [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;
};

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 <div>${joins}${literals}</div>;`, `return <div>${literals}${joins}</div>;`),
).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 = <i>${children(100, i => `{p.c${i} ? 1 : 2}`)}</i>;`;
const read = `return <div>{joined}${locals.map(local => `{${local}}`).join("")}</div>;`;
// Without the fix: 300 MB.
expect(await overItsTwin(`${joined} ${assign} ${read}`, `${assign} ${joined} ${read}`)).toBeLessThan(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} ? <a onClick={() => f(${i})} /> : null}`);
const outside = children(400, i => `{p.c${i} ? <a /> : null}`) + children(400, i => `{() => f(${i})}`);
// With the successor still counted: 80 MB.
expect(
await overItsTwin(`return <div>${literals}${outside}</div>;`, `return <div>${literals}${inside}</div>;`),
).toBeLessThan(40);
});
});

// 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,
Expand Down
Loading