Skip to content

Instantly share code, notes, and snippets.

@Jarred-Sumner
Created June 20, 2026 21:06
Show Gist options
  • Select an option

  • Save Jarred-Sumner/84df369c35216f40d61d1ea9f95771c2 to your computer and use it in GitHub Desktop.

Select an option

Save Jarred-Sumner/84df369c35216f40d61d1ea9f95771c2 to your computer and use it in GitHub Desktop.
React Compiler Rust port — perf notes from Bun integration

React Compiler Rust port — perf notes from Bun's integration

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.


1. InferMutationAliasingEffects — make the fixpoint state memcpy-able

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.

1a. AbstractValueCopy (8 bytes)

#[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.

1b. InferenceState.values → dense Vec<Option<AbstractValue>>

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.0

Clone 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>,

1c. Points-to sets → 16-byte inline ValueIdSet (cap 3)

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.

1d. variablesDense for the top-level function, Sparse for nested

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.

1e. Cache the per-instruction signature across iterations

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.


2. InferReactivePlacesHashSet<Identifier>Vec<bool>

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.


3. IndexMap / IndexSet → arena-backed, plus IdMap

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} for FxHashMap/FxHashSet (rustc_hash) everywhere a u32-newtype is the key. The default SipHash is ~3× slower than Fx on integer keys and dominates several passes.

  • Adding an IdMap<K, V> that stores keys as raw u32 so 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>);

4. HIR storage — no Strings, ids index flat arenas

These may already match what compiler/crates/* has; listing for completeness.

  • Every id is a u32 newtype (BlockId, IdentifierId, InstructionId, DeclarationId, ScopeId, TypeId, FunctionId, MutableRangeId).
  • Place carries an IdentifierId (4 bytes), not an Identifier object.
  • Environment holds flat Vec<Identifier> / Vec<Type> / Vec<ReactiveScope> / Vec<HirFunction> indexed by those ids; next_identifier_id is IdentifierId(self.identifiers.len() as u32).
  • IdentifierName is Named(&'arena [u8]) / Promoted(&'arena [u8]) — never String.
  • MutableRange identity is by id: MutableRangeId(u32) equality, not JS object identity:
    pub fn same_range(&self, other: &Self) -> bool { self.id == other.id }
  • ShapeRegistry interns anon-shape ids in a process-wide pool so repeated Environment::with_config calls reuse the same handful of &'static str instead of leaking one per component.

5. Pipeline — bail early on any recorded error

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.


6. Not yet densified (next targets)

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.rs
  • reactive_scopes/build_reactive_function.rs
  • reactive_scopes/prune_*.rs
  • reactive_scopes/rename_variables.rs
  • typeinference/infer_types.rs (substitutions: HashMap<TypeId, Type>)
  • hir/environment.rs module-type / hoisted-identifier tables

Reference

Bun's port lives at https://github.com/oven-sh/bun/tree/main/src/react_compilerinference/infer_mutation_aliasing_effects.rs is the most-changed file and has the full ValueIdSet / Variables implementation with comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment