Bun ships the React Compiler as a built-in transform (oven-sh/bun#32504). The
HIR-level passes are a direct port of compiler/crates/* with the
data-structure changes below; lowering and codegen are rewritten against Bun's
own AST and aren't relevant here. These notes cover only the HIR-pass changes
that should apply equally to the upstream Rust crates.
Headline: on a ~860-component production codebase, total pass time dropped
from ~2.5s → ~0.5s aggregate user time. InferMutationAliasingEffects alone
went from ~1850ms → ~440ms (≈4.2×) and is still the dominant pass after the
changes.
Everything below is mechanical enough that an agent given this file plus the upstream crate sources should be able to apply it.
This pass clones InferenceState at every block boundary on every fixpoint
iteration. Upstream's state holds HashMaps and HashSets, so each clone is
O(entries) rehashing plus O(entries) heap allocs. The goal is to make clone()
one alloc + one memcpy and merge_from() one elementwise loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AbstractValue {
kind: ValueKind, // u8 enum
reason: ValueReasonSet,
}reason was HashSet<ValueReason> upstream. There are 12 ValueReason
variants — replace the set with an enumset::EnumSet<ValueReason> (one u16):
#[derive(enumset::EnumSetType, Debug)]
pub enum ValueReason {
KnownReturnSignature, State, ReducerState, Context, Effect,
HookCaptured, HookReturn, Global, JsxCaptured, StoreLocal,
ReactiveFunctionArgument, Other,
}
pub type ValueReasonSet = enumset::EnumSet<ValueReason>;
// keep upstream call sites textually unchanged
#[inline]
fn hashset_of(r: ValueReason) -> ValueReasonSet { ValueReasonSet::only(r) }This is the load-bearing change: it turns AbstractValue into a Copy cell
and unblocks everything below.
Upstream keys the value table by HIR object identity
(Map<InstructionValue, AbstractValue>). Mint a pass-local ValueId(u32) per
allocation site (one counter on Context), and store values as
values: Vec<Option<AbstractValue>>, // indexed by ValueId.0Clone is Vec::clone of Copy cells (one memcpy); merge_from is
for (a, b) in self.values.iter_mut().zip(&other.values) {
match (a, b) { (Some(a), Some(b)) => a.merge(*b), (None, Some(b)) => *a = Some(*b), _ => {} }
}The ValueId for an effect/allocation must be stable across fixpoint
iterations so that merge_from is comparing the same site each round. Cache
it on Context:
effect_value_id_cache: FxHashMap<u64 /* effect hash */, ValueId>,Typical cardinality of an identifier's points-to set is 1; phis with N
predecessors merge N. Replace HashSet<ValueId> with a 16-byte inline
small-set:
// 16 bytes: [u32; 3] inline storage + u32 len. Past 3 entries the words are
// repurposed as (heap ptr, capacity).
struct ValueIdSet { words: [u32; 3], len: u32 }
const _: () = assert!(size_of::<ValueIdSet>() == 16);
impl ValueIdSet { const INLINE_CAP: u32 = 3; ... }Hand-roll Clone/Drop so that, when no element has spilled, cloning a
Vec<ValueIdSet> is one memcpy and dropping it is one dealloc. Track
"any element spilled" as a single bool on the owning container (next item) so
the no-spill clone path is branchless.
IdentifierId is dense and contiguous within a single function's
env.identifiers. For the top-level component (where almost all fixpoint
time is spent), store one ValueIdSet per identifier in a flat Vec:
enum Variables {
Dense { vec: Vec<ValueIdSet>, any_heap: bool },
Sparse(FxHashMap<IdentifierId, ValueIdSet>),
}
fn empty(is_function_expression: bool, identifier_capacity: usize) -> Self {
if is_function_expression {
Variables::Sparse(FxHashMap::default())
} else {
let mut vec = Vec::with_capacity(identifier_capacity);
vec.resize_with(identifier_capacity, ValueIdSet::new);
Variables::Dense { vec, any_heap: false }
}
}Dense::clone checks any_heap: when false, ptr::copy_nonoverlapping the
whole buffer; when true, fall back to elementwise clone. merge_from gets a
dense×dense fast path (in-place elementwise union, no rehashing).
Nested function expressions only see a sparse subset of identifiers, so keep
the HashMap for them — building a full-width dense vec there would waste
memory without buying speed.
infer_instruction_signature is pure given the instruction and environment.
Cache its result on Context keyed by InstructionId.0:
instruction_signature_cache: FxHashMap<u32, InstructionSignature>,so the second and later fixpoint rounds skip the recomputation entirely.
Replace the reactive-identifier set with a Vec<bool> indexed by
IdentifierId.0:
reactive: Vec<bool>, // reactive[place.identifier.0 as usize]Same shape as 1b — IdentifierId is already dense.
Bun shims the indexmap API over an arena-backed insertion-ordered map so
every map/set built during a compile is bulk-freed on arena reset (no per-entry
Drop). Upstream can get most of the win without an arena by:
-
Swapping
std::collections::{HashMap, HashSet}forFxHashMap/FxHashSet(rustc_hash) everywhere au32-newtype is the key. The defaultSipHashis ~3× slower than Fx on integer keys and dominates several passes. -
Adding an
IdMap<K, V>that stores keys as rawu32so every id-newtype shares one monomorphization of the underlying map (cuts compile time and i-cache pressure, not runtime, but it's free):pub struct IdMap<K, V>(IndexMap<u32, V>, PhantomData<K>);
These may already match what compiler/crates/* has; listing for completeness.
- Every id is a
u32newtype (BlockId,IdentifierId,InstructionId,DeclarationId,ScopeId,TypeId,FunctionId,MutableRangeId). Placecarries anIdentifierId(4 bytes), not anIdentifierobject.Environmentholds flatVec<Identifier>/Vec<Type>/Vec<ReactiveScope>/Vec<HirFunction>indexed by those ids;next_identifier_idisIdentifierId(self.identifiers.len() as u32).IdentifierNameisNamed(&'arena [u8])/Promoted(&'arena [u8])— neverString.MutableRangeidentity is byid: MutableRangeId(u32)equality, not JS object identity:pub fn same_range(&self, other: &Self) -> bool { self.id == other.id }
ShapeRegistryinterns anon-shape ids in a process-wide pool so repeatedEnvironment::with_configcalls reuse the same handful of&'static strinstead of leaking one per component.
This is a correctness-of-port issue, not a data-structure change, but it cost ~250ms wall time on the test codebase so it's worth double-checking:
The Rust port should match the TS pipeline's bail points exactly. TS lower()
returns Err on builder.errors.hasAnyErrors(); if the port only checks for
Invariant errors there, functions that record a non-Invariant Todo during
lowering (throw-inside-try/catch, var declarations) run the full 30-pass
pipeline before being discarded at the final has_errors() gate. Same for the
post-InferMutationAliasingRanges validation block — TS .unwrap()s there,
which throws on any error; the port should if env.has_errors() { return Err }
before entering the reactive-scope passes.
Passes still on std::collections::{HashMap, HashSet} with default hasher in
Bun's port — same treatment as §1/§2 should apply:
reactive_scopes/promote_used_temporaries.rsreactive_scopes/build_reactive_function.rsreactive_scopes/prune_*.rsreactive_scopes/rename_variables.rstypeinference/infer_types.rs(substitutions: HashMap<TypeId, Type>)hir/environment.rsmodule-type / hoisted-identifier tables
Bun's port lives at
https://github.com/oven-sh/bun/tree/main/src/react_compiler —
inference/infer_mutation_aliasing_effects.rs is the most-changed file and
has the full ValueIdSet / Variables implementation with comments.