From 4694ee2f582fb93b5fb2902bb2b51097970f3b1c Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Tue, 18 Aug 2026 19:55:55 +0900 Subject: [PATCH 1/6] majit: keep an inlined region's live-ins out of the label capture plan `LabelResumeData::collect` runs over the merged analysis stream, owner ops followed by each appended bridge region. A region's live-ins are inputargs of that stream, so they are defined before every resumable LABEL, and they appear in no LABEL argument list, so every read of them after a LABEL was recorded as a frame capture at that LABEL. A region is reachable only through the guard-fail branch that is its sole predecessor, and that branch assigns those values, so nothing the entry dispatch can land on reaches a region's first read without passing through their definition. `collect_with_regions` therefore takes the merge site's region spans and marks each region's live-in ids available at every label positioned before it. The fresh-entry home clear skipped any home the entry input loop fills. That loop walks `entry_inputargs`, not the merged list, so a region live-in's home is not filled there; it was only cleared because the capture plan placed it in the high LABEL-capture region, which is zeroed unconditionally. Computing the skip from `entry_inputargs` keeps those homes cleared once they become ordinary homes. Measured with `PYRE_WASM_INLINE_BRIDGE=1`, two modules from one tree switched by `--module`, fuel of record, stdout identical on every fixture: global_quasiimmut_invalidation -19.77% -> -22.00% kept_stack_depth_gt1_heap -7.00% -> -8.09% kept_stack_depth_gt1 -4.78% -> -5.91% if_else_jump_forward -3.22% -> -3.67% short_circuit_value_kept_stack +3.31% -> -0.27% inline_multiframe_branchy_carrier +273.25% -> -8.41% `inline_decl_label_resume_layout` and the `inline_geometry` shortage records go to zero on the three fixtures that reported them, and one more inline is accepted on each of those. Assisted-by: Claude --- majit/majit-backend-wasm/src/codegen.rs | 51 ++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/majit/majit-backend-wasm/src/codegen.rs b/majit/majit-backend-wasm/src/codegen.rs index 5a5ad1643e9..4a76dccfe49 100644 --- a/majit/majit-backend-wasm/src/codegen.rs +++ b/majit/majit-backend-wasm/src/codegen.rs @@ -895,8 +895,23 @@ struct LabelResumeData { ref_slots: usize, } +/// Where one inlined bridge region starts in a merged analysis stream, and +/// which value ids carry that region's own live-ins. +struct InlinedRegionSpan { + ops_start: usize, + inputarg_ids: Vec, +} + impl LabelResumeData { fn collect(inputargs: &[InputArg], ops: &[Op]) -> Self { + Self::collect_with_regions(inputargs, ops, &[]) + } + + fn collect_with_regions( + inputargs: &[InputArg], + ops: &[Op], + regions: &[InlinedRegionSpan], + ) -> Self { let (_, num_vars) = collect_guards_and_vars(inputargs, ops); let ref_values = RefValues::collect(inputargs, ops); let normal_value_slots = normal_frame_value_slots(inputargs, ops); @@ -963,6 +978,24 @@ impl LabelResumeData { *v = true; } } + // An appended region's live-ins reach it only through the + // guard-fail branch that is the region's sole predecessor, and that + // branch assigns them. Nothing the entry dispatch can land on + // reaches a region's first read without passing it, so those ids + // are dead until written here. Treating them as live would reserve + // one frozen-frame slot per region live-in at every resumable + // label, and the resume loader would reload a value the guard + // overwrites before anything reads it. + for region in regions { + if region.ops_start <= label_pos { + continue; + } + for &id in ®ion.inputarg_ids { + if let Some(v) = available.get_mut(id as usize) { + *v = true; + } + } + } let mut missing = Vec::new(); let mut bad = false; @@ -2289,12 +2322,20 @@ pub fn build_wasm_module( let mut merged_inputargs = Vec::new(); let mut merged_ops = Vec::new(); let mut gc_table_bases = HashMap::new(); + let mut region_spans: Vec = Vec::new(); let (analysis_inputargs, analysis_ops): (&[InputArg], &[Op]) = if inlined_bridges.is_empty() { (inputargs, ops) } else { merged_inputargs.extend(inputargs.iter().map(InputArg::fresh_value_copy)); merged_ops.extend(ops.iter().cloned()); for bridge in inlined_bridges { + // Taken before this region's ops are appended, so it names the + // first op of the region. `fresh_value_copy` keeps `index`, so the + // ids recorded here are the ids the merged stream reads. + region_spans.push(InlinedRegionSpan { + ops_start: merged_ops.len(), + inputarg_ids: bridge.inputargs.iter().map(|ia| ia.index).collect(), + }); merged_inputargs.extend(bridge.inputargs.iter().map(InputArg::fresh_value_copy)); for op in &bridge.ops { if op.opcode == OpCode::LoadFromGcTable { @@ -2383,7 +2424,8 @@ pub fn build_wasm_module( // Ref homes, and the always-present tail call area; a chained bridge must // fit the source token's frozen value-slot count before it can share that // frame. - let label_resume = LabelResumeData::collect(&analysis_inputargs, &analysis_ops); + let label_resume = + LabelResumeData::collect_with_regions(&analysis_inputargs, &analysis_ops, ®ion_spans); let max_value_slots = normal_frame_value_slots(&analysis_inputargs, &analysis_ops) + label_resume.scalar_slots; if max_value_slots > frame.value_slots { @@ -3039,8 +3081,13 @@ fn build_function( // immediately and nothing between the two allocates, so no collection can // read the slot while it is stale. Homes no input fills keep their clear // because store-on-def writes them only later. + // The loop below fills `entry_inputargs`, not every arg of the merged + // stream: an appended region's live-ins are stored by the guard-fail branch + // that reaches the region, which is nowhere near this entry. Marking those + // homes filled here would skip their clear and leave the collector reading + // an uninitialised slot. let mut input_filled_home = vec![false; ref_homes.len()]; - for ia in inputargs { + for ia in entry_inputargs { if let Some(h) = ref_homes.home_id(ia.index) { input_filled_home[h as usize] = true; } From 4c23d37335c2478b535e8c1dff6d87901db02e09 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Tue, 18 Aug 2026 20:27:44 +0900 Subject: [PATCH 2/6] majit: replay chained bridge dispatch cells across a loop re-emission `compile_bridge` writes a dispatch cell for every accepted bridge, but recorded the slot in `CompiledWasmLoop::bridge_slots` only when the failing guard belongs to the loop itself. `reemit_loop` replays its freshly allocated cell array from that map alone. A bridge chained onto a guard inside a region merged into the loop has its cells carved out of the loop array (`chained_trace_meta[trace_id].cells_base = new_cells_base + offset * 4`), which a re-emission reallocates, so its cell was dropped and never restored. A standalone chained bridge keeps its own module's array and was unaffected. Add `chained_bridge_slots`, keyed by the guard's owning trace id and per-trace fail index, recorded at the same site for the non-direct case and replayed in the `chained_trace_meta` rebuild where each region's new base is known. Measured with `PYRE_WASM_INLINE_BRIDGE=1` on `kept_stack_branch_depths`: `FIRED` 148 -> 11, `walks` 149 -> 12, `wasm_ops` +600.79% -> +1.62%. Over the 429 synthetic fixtures the flag's aggregate goes +0.918% -> +0.008% with no output mismatch. Assisted-by: Claude --- majit/majit-backend-wasm/src/failguard.rs | 7 +++++ majit/majit-backend-wasm/src/lib.rs | 36 +++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/majit/majit-backend-wasm/src/failguard.rs b/majit/majit-backend-wasm/src/failguard.rs index 8a5099699dd..8d1df06ba69 100644 --- a/majit/majit-backend-wasm/src/failguard.rs +++ b/majit/majit-backend-wasm/src/failguard.rs @@ -674,6 +674,13 @@ pub struct CompiledWasmLoop { /// Direct-loop guard index to bridge table slot. A re-emission replays /// these slots into its fresh loop cell array. pub bridge_slots: RefCell>, + /// The same, for a guard that lives inside a trace chained onto this loop, + /// keyed by `(owning trace_id, per-trace fail index)`. A standalone chained + /// bridge keeps its cells in its own module's array, which survives; a + /// region merged into this loop does not, because a re-emission reallocates + /// the loop array its guards are carved out of. Replayed once the rebuilt + /// `chained_trace_meta` names the new bases. + pub chained_bridge_slots: RefCell>, /// Post-intern module inputs retained for a loop re-emission. Entry /// bridges store `None` because they tail-call another loop. pub reemit: RefCell>, diff --git a/majit/majit-backend-wasm/src/lib.rs b/majit/majit-backend-wasm/src/lib.rs index 7bdc2969ff2..b132099ee41 100644 --- a/majit/majit-backend-wasm/src/lib.rs +++ b/majit/majit-backend-wasm/src/lib.rs @@ -1987,6 +1987,24 @@ impl WasmBackend { for region in &inputs.inlined_bridges { let count = codegen::guard_exit_count(®ion.inputargs, ®ion.ops); let exits = &guard_exits[offset..offset + count]; + // This region's guards are carved out of the array that was + // just reallocated, so every bridge already chained onto one of + // them has lost its dispatch entry. Unreplayed, that guard + // deopts to the tracer on every failure and retraces a bridge + // it can never reach. + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + if new_cells_base != 0 { + for (&(trace_id, fail_index), &bridge_slot) in + compiled.chained_bridge_slots.borrow().iter() + { + if trace_id != region.trace_id || fail_index as usize >= count { + continue; + } + let cell = (new_cells_base as usize + (offset + fail_index as usize) * 4) + as *mut u32; + unsafe { core::ptr::write(cell, bridge_slot) }; + } + } metas.insert( region.trace_id, ChainedTraceMeta { @@ -2889,6 +2907,7 @@ impl majit_backend::Backend for WasmBackend { chained_trace_meta: std::cell::RefCell::new(std::collections::HashMap::new()), _bridge_owned_cells: std::cell::RefCell::new(bridge_cells_owner.into_iter().collect()), bridge_slots: std::cell::RefCell::new(HashMap::new()), + chained_bridge_slots: std::cell::RefCell::new(HashMap::new()), // Retaining the snapshot costs long-lived heap for the token's // whole lifetime, which moves when the collector next runs and so // moves which iteration a back edge's eval-breaker guard bails on. @@ -3646,16 +3665,23 @@ impl majit_backend::Backend for WasmBackend { } // Retained module replacement and loop-closing bridge inlining // restore this cell after allocating a fresh dispatch array. - if is_direct && (reemit_enabled() || inline_bridge_enabled()) { + if reemit_enabled() || inline_bridge_enabled() { if let Some(source_loop) = original_token .compiled .get() .and_then(|c| c.downcast_ref::()) { - source_loop - .bridge_slots - .borrow_mut() - .insert(source_fail_index, bridge_slot); + if is_direct { + source_loop + .bridge_slots + .borrow_mut() + .insert(source_fail_index, bridge_slot); + } else { + source_loop + .chained_bridge_slots + .borrow_mut() + .insert((source_trace_id, source_fail_index), bridge_slot); + } } } } From d153448a40e93f0044eca3c48c95f4a3bd653f92 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 09:30:11 +0900 Subject: [PATCH 3/6] majit: rebase an inlined bridge region's value ids off the owner's The owner trace and each region merged into it are separately recorded and number their values independently from zero, and `InputArg::fresh_value_copy` keeps `index`, so concatenating the streams aliased a region's values onto the owner's in the merged stream's single local namespace. A region is entered by storing into the local its input arg names and leaves through the loop header, so an id it shared with an owner value live across the back edge overwrote that value for every following iteration. `build_wasm_module` now shifts each region's input args, op positions, args and fail args onto a range above everything already merged, and emits the guard entry moves and region bodies from the rebased copies. Rebinding through `Operand::bound_from_opref` leaves the retained region's own producers untouched. Adds a test asserting the emitted module is byte-identical whether the region numbers its values from an id the owner uses or from one above every owner id. Assisted-by: Claude --- majit/majit-backend-wasm/src/codegen.rs | 124 +++++++++++++++++- .../majit-backend-wasm/tests/codegen_test.rs | 120 +++++++++++++++++ 2 files changed, 240 insertions(+), 4 deletions(-) diff --git a/majit/majit-backend-wasm/src/codegen.rs b/majit/majit-backend-wasm/src/codegen.rs index 4a76dccfe49..f2359c65301 100644 --- a/majit/majit-backend-wasm/src/codegen.rs +++ b/majit/majit-backend-wasm/src/codegen.rs @@ -2184,9 +2184,9 @@ pub struct ModuleBuildInputs { /// already allocated GC-table base encoded by `gc_table_base`. pub ops: Vec, /// Loop-closing bridge regions emitted inside this loop's wasm function. - /// Their value ids are already in the owning trace's global space; retain - /// them verbatim so every analysis and the generated locals see the same - /// identities as the bridge metadata. + /// Each is retained in its own trace's numbering; `build_wasm_module` + /// rebases them onto a private id range before merging, because the owner + /// and every region number their values independently from zero. pub inlined_bridges: Vec, pub constants: indexmap::IndexMap, pub vtable_offset: Option, @@ -2284,6 +2284,108 @@ impl Clone for ModuleBuildInputs { } } +/// One past the highest value id `inputargs`/`ops` define or read. Mirrors the +/// `max_var` half of `collect_guards_and_vars` without its guard collection, +/// which stamps per-value counters onto guard descrs and must run once only. +fn value_id_end(inputargs: &[InputArg], ops: &[Op]) -> u32 { + let mut end: u32 = 0; + let widen = |r: OpRef, end: &mut u32| { + if r != OpRef::NONE && !r.is_constant() && r.raw() + 1 > *end { + *end = r.raw() + 1; + } + }; + for ia in inputargs { + if ia.index + 1 > end { + end = ia.index + 1; + } + } + for op in ops { + widen(op.pos.get(), &mut end); + for a in op.getarglist().iter() { + widen(a.to_opref(), &mut end); + } + if let Some(fa) = op.getfailargs() { + for a in fa.iter() { + widen(a.to_opref(), &mut end); + } + } + } + end +} + +/// Move every value id a region defines or reads up by `offset`, returning the +/// rebased region and the width of the id range it now occupies. +/// +/// The owner trace and each region are separately recorded traces, so both +/// number their values from zero and their ids overlap. A region is entered by +/// `local.set`ting the id each of its input args carries +/// (`emit_guard_inline_bridge_move`) and leaves through the loop header, so an +/// id it shares with an owner value that is live across the back edge +/// overwrites that value for every following iteration. Rebasing onto a +/// disjoint range is what makes the merged stream's single local namespace +/// sound. +/// +/// `TempVar` ids live in a reserved high strip and constants in their own +/// namespace; neither indexes a value local, so both pass through unchanged. +fn rebase_region_value_ids(bridge: &InlinedBridge, offset: u32) -> (InlinedBridge, u32) { + use majit_ir::operand::Operand; + + let shift = |r: OpRef| -> OpRef { + if r.is_none() || r.is_constant() || r.is_temp_var() { + r + } else { + r.with_raw(r.raw() + offset) + } + }; + + let width = value_id_end(&bridge.inputargs, &bridge.ops); + let inputargs: Vec = bridge + .inputargs + .iter() + .map(|ia| InputArg::from_type(ia.tp, ia.index + offset)) + .collect(); + // `Op::clone` gives the copy its own arg/failarg slots, but the operands in + // them keep pointing at the region's original producers, whose `pos` this + // must not touch — the region is retained for the next re-emission. So each + // moved reference is rebound to a synthetic producer carrying the new id. + let ops: Vec = bridge.ops.to_vec(); + for op in &ops { + op.pos.set(shift(op.pos.get())); + for (i, arg) in op.getarglist().iter().enumerate() { + let before = arg.to_opref(); + let after = shift(before); + if after != before { + op.setarg(i, Operand::bound_from_opref(after)); + } + } + if let Some(mut fail_args) = op.getfailargs() { + let mut moved = false; + for slot in fail_args.iter_mut() { + let before = slot.to_opref(); + let after = shift(before); + if after != before { + *slot = Operand::bound_from_opref(after); + moved = true; + } + } + if moved { + op.setfailargs(fail_args); + } + } + } + + ( + InlinedBridge { + source_fail_index: bridge.source_fail_index, + trace_id: bridge.trace_id, + inputargs, + ops, + gc_table_base: bridge.gc_table_base, + }, + width, + ) +} + /// Build a wasm module from majit IR. pub fn build_wasm_module( inputs: &ModuleBuildInputs, @@ -2323,12 +2425,18 @@ pub fn build_wasm_module( let mut merged_ops = Vec::new(); let mut gc_table_bases = HashMap::new(); let mut region_spans: Vec = Vec::new(); + let mut rebased_bridges: Vec = Vec::new(); let (analysis_inputargs, analysis_ops): (&[InputArg], &[Op]) = if inlined_bridges.is_empty() { (inputargs, ops) } else { merged_inputargs.extend(inputargs.iter().map(InputArg::fresh_value_copy)); merged_ops.extend(ops.iter().cloned()); + // The merged stream has one local namespace, so every region has to be + // moved off the ids the owner and the earlier regions already use. + let mut next_value_id = value_id_end(inputargs, ops); for bridge in inlined_bridges { + let (bridge, width) = rebase_region_value_ids(bridge, next_value_id); + next_value_id += width; // Taken before this region's ops are appended, so it names the // first op of the region. `fresh_value_copy` keeps `index`, so the // ids recorded here are the ids the merged stream reads. @@ -2343,9 +2451,17 @@ pub fn build_wasm_module( } } merged_ops.extend(bridge.ops.iter().cloned()); + rebased_bridges.push(bridge); } (&merged_inputargs, &merged_ops) }; + // Guard-entry moves and region emission must name the rebased ids, not the + // ids the retained regions still carry. + let emitted_bridges: &[InlinedBridge] = if inlined_bridges.is_empty() { + inlined_bridges + } else { + &rebased_bridges + }; let (mut guards, num_vars) = collect_guards_and_vars(analysis_inputargs, analysis_ops); // An inlined bridge branches back into the owner with wasm `br`. The @@ -2758,7 +2874,7 @@ pub fn build_wasm_module( inputargs, &analysis_inputargs, &analysis_ops, - inlined_bridges, + emitted_bridges, constants, num_vars, &value_types, diff --git a/majit/majit-backend-wasm/tests/codegen_test.rs b/majit/majit-backend-wasm/tests/codegen_test.rs index ef0b76395ad..deb2e31035f 100644 --- a/majit/majit-backend-wasm/tests/codegen_test.rs +++ b/majit/majit-backend-wasm/tests/codegen_test.rs @@ -1304,6 +1304,126 @@ fn inlined_bridge_without_owner_loop_label_declines() { assert!(error.to_string().contains("no local loop LABEL")); } +/// A region's value ids are its own trace's, so they collide with the owner's. +/// The merged stream has one local namespace, so an unrebased collision makes +/// the region's entry moves land in locals the owner still holds live across +/// the back edge. Where a region's numbering happens to start must therefore +/// not be observable in the emitted code. +#[test] +fn inlined_bridge_emission_is_independent_of_the_regions_own_numbering() { + fn owner_ops() -> Vec { + vec![ + // Defined before the LABEL and read after it, so it is live across + // the back edge and is restored only on preamble/resume entry. + make_op( + OpCode::IntAdd, + &[OpRef::input_arg_int(0), OpRef::input_arg_int(1)], + OpRef::int_op(2), + ), + Op::new( + OpCode::Label, + &[rb(OpRef::input_arg_int(0)), rb(OpRef::input_arg_int(1))], + ), + make_op( + OpCode::IntAdd, + &[OpRef::input_arg_int(0), OpRef::const_int(1)], + OpRef::int_op(3), + ), + make_op( + OpCode::IntLt, + &[OpRef::int_op(3), OpRef::const_int(10)], + OpRef::int_op(4), + ), + make_guard( + OpCode::GuardTrue, + &[OpRef::int_op(4)], + &[OpRef::int_op(3), OpRef::input_arg_int(1)], + ), + make_op( + OpCode::IntAdd, + &[OpRef::input_arg_int(1), OpRef::int_op(2)], + OpRef::int_op(5), + ), + make_op( + OpCode::IntLt, + &[OpRef::int_op(5), OpRef::const_int(1000)], + OpRef::int_op(6), + ), + make_guard( + OpCode::GuardTrue, + &[OpRef::int_op(6)], + &[OpRef::int_op(3), OpRef::int_op(5)], + ), + Op::new(OpCode::Jump, &[rb(OpRef::int_op(3)), rb(OpRef::int_op(5))]), + ] + } + + // `base` picks where the region numbers its own values. `base = 2` makes + // its first input arg share an id with the owner's loop-invariant + // `int_op(2)`; `base = 40` clears every owner id. + fn build(base: u32) -> Vec { + let inputargs = vec![ + InputArg::from_type(Type::Int, 0), + InputArg::from_type(Type::Int, 1), + ]; + let region_ops = vec![ + make_op( + OpCode::IntAdd, + &[OpRef::input_arg_int(base), OpRef::input_arg_int(base + 1)], + OpRef::int_op(base + 2), + ), + Op::new( + OpCode::Jump, + &[ + rb(OpRef::int_op(base + 2)), + rb(OpRef::input_arg_int(base + 1)), + ], + ), + ]; + let inputs = codegen::ModuleBuildInputs { + inputargs: inputargs.iter().map(InputArg::fresh_value_copy).collect(), + ops: owner_ops(), + inlined_bridges: vec![codegen::InlinedBridge { + source_fail_index: 1, + trace_id: 7, + inputargs: vec![ + InputArg::from_type(Type::Int, base), + InputArg::from_type(Type::Int, base + 1), + ], + ops: region_ops, + gc_table_base: 0, + }], + constants: indexmap::IndexMap::new(), + vtable_offset: Some(0), + classptr_to_typeid: HashMap::new(), + guard_gc_type_info: codegen::GuardGcTypeInfo::default(), + alloc: codegen::AllocHelpers::default(), + wb_fn_ptr: 0, + nursery: None, + invalidated_flag_addr: 0, + gc_table_base: 0, + fail_index_base: 0, + bridge_cells_base: 0, + bridge_entry_arity: None, + bridge_param_dispatch: false, + trace_entry_census: None, + external_jump_slot: 0, + external_jump_key: 0, + frame: codegen::FrameGeometry::fixed(), + ca: codegen::CaParams::default(), + }; + codegen::build_wasm_module(&inputs) + .expect("a loop-closing region merges into its owner") + .0 + } + + let colliding = build(2); + let disjoint = build(40); + validate_wasm(&colliding); + validate_wasm(&disjoint); + assert_eq!(colliding, disjoint); +} + #[test] fn test_int_add_loop() { // Label(i, sum) -> IntAdd(sum, i) -> IntAdd(i, 1) -> IntLt(i, 100) From 1becb5c0a5a201387e31e4b94c501bdfd27839bb Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 10:26:18 +0900 Subject: [PATCH 4/6] majit: date an inlined region's live-ins to the region, not to trace entry A region's input args join the merged stream's input-arg list, which `HomeLiveness` dates to trace entry. Their last use is inside the region, at the tail of the merged stream, so every collecting call in the owner's body sat inside their live range: each Ref one took an ordinary home and was reloaded after every such call, on every iteration, for a value nothing in the owner reads. On `nested_callee_chain_mutation_abort` that was 100 reloads across the loop body's 8 collecting calls, 300 charged ops per iteration. The guard-fail branch that is a region's sole predecessor writes those locals and jumps straight into the region, so their definition point is the region. `HomeLiveness::collect_with_regions` dates them there, which leaves them live across the region's own collect points and nowhere else. `InlinedRegionSpan::collect` now builds the spans for both `build_wasm_module` and `build_function`, so the homes `RefHomes` reserves and the reload filter agree on one set of spans. Assisted-by: Claude --- majit/majit-backend-wasm/src/codegen.rs | 66 ++++++++++++++++++++----- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/majit/majit-backend-wasm/src/codegen.rs b/majit/majit-backend-wasm/src/codegen.rs index f2359c65301..935d727f8a6 100644 --- a/majit/majit-backend-wasm/src/codegen.rs +++ b/majit/majit-backend-wasm/src/codegen.rs @@ -792,8 +792,9 @@ impl RefHomes { ops: &[Op], include_ca_collects: bool, forced_refs: &[OpRef], + regions: &[InlinedRegionSpan], ) -> Self { - let liveness = HomeLiveness::collect(inputargs, ops); + let liveness = HomeLiveness::collect_with_regions(inputargs, ops, regions); let collect_positions = collecting_call_positions(ops, include_ca_collects); let ref_values = RefValues::collect(inputargs, ops); let mut by_id = Vec::new(); @@ -902,6 +903,28 @@ struct InlinedRegionSpan { inputarg_ids: Vec, } +impl InlinedRegionSpan { + /// The regions occupy the tail of the merged stream in `inlined_bridges` + /// order, so their starts run back from the end of `ops`. `bridges` must be + /// the rebased copies the merged stream was built from, so the recorded ids + /// are the ids that stream reads. + fn collect(ops_len: usize, bridges: &[InlinedBridge]) -> Vec { + let mut start = + ops_len.saturating_sub(bridges.iter().map(|bridge| bridge.ops.len()).sum::()); + bridges + .iter() + .map(|bridge| { + let span = Self { + ops_start: start, + inputarg_ids: bridge.inputargs.iter().map(|ia| ia.index).collect(), + }; + start += bridge.ops.len(); + span + }) + .collect() + } +} + impl LabelResumeData { fn collect(inputargs: &[InputArg], ops: &[Op]) -> Self { Self::collect_with_regions(inputargs, ops, &[]) @@ -1120,7 +1143,7 @@ pub fn count_ref_homes(inputargs: &[InputArg], ops: &[Op]) -> usize { // This pre-sizing query is used for CA bridges before `CaParams` exists, so // count CALL_ASSEMBLER as a collecting position to match CA codegen. let resume = LabelResumeData::collect(inputargs, ops); - RefHomes::collect(inputargs, ops, true, &resume.captured_refs).len() + RefHomes::collect(inputargs, ops, true, &resume.captured_refs, &[]).len() } /// Number of high GC-rooted homes reserved exclusively for LABEL live-ins. @@ -1369,7 +1392,11 @@ struct HomeLiveness { } impl HomeLiveness { - fn collect(inputargs: &[InputArg], ops: &[Op]) -> Self { + fn collect_with_regions( + inputargs: &[InputArg], + ops: &[Op], + regions: &[InlinedRegionSpan], + ) -> Self { let mut n = inputargs .iter() .map(|ia| ia.index as usize + 1) @@ -1412,6 +1439,22 @@ impl HomeLiveness { } } } + // An appended region's live-ins are written by the guard-fail branch + // that is the region's sole predecessor, and that branch jumps straight + // into the region. Their entry in the merged input-arg list would + // otherwise date them to trace entry, making them live across every + // collecting call in the owner's body: each would take a Ref home and + // be reloaded there on every iteration, for a value nothing in the + // owner reads. Date them to the region instead, so they stay live + // across the region's own collect points and nowhere else. + for region in regions { + let defined_at = region.ops_start as i32 - 1; + for &id in ®ion.inputarg_ids { + if let Some(d) = def_pos.get_mut(id as usize) { + *d = defined_at; + } + } + } Self { def_pos, last_use } } @@ -2424,7 +2467,6 @@ pub fn build_wasm_module( let mut merged_inputargs = Vec::new(); let mut merged_ops = Vec::new(); let mut gc_table_bases = HashMap::new(); - let mut region_spans: Vec = Vec::new(); let mut rebased_bridges: Vec = Vec::new(); let (analysis_inputargs, analysis_ops): (&[InputArg], &[Op]) = if inlined_bridges.is_empty() { (inputargs, ops) @@ -2437,13 +2479,6 @@ pub fn build_wasm_module( for bridge in inlined_bridges { let (bridge, width) = rebase_region_value_ids(bridge, next_value_id); next_value_id += width; - // Taken before this region's ops are appended, so it names the - // first op of the region. `fresh_value_copy` keeps `index`, so the - // ids recorded here are the ids the merged stream reads. - region_spans.push(InlinedRegionSpan { - ops_start: merged_ops.len(), - inputarg_ids: bridge.inputargs.iter().map(|ia| ia.index).collect(), - }); merged_inputargs.extend(bridge.inputargs.iter().map(InputArg::fresh_value_copy)); for op in &bridge.ops { if op.opcode == OpCode::LoadFromGcTable { @@ -2462,6 +2497,7 @@ pub fn build_wasm_module( } else { &rebased_bridges }; + let region_spans = InlinedRegionSpan::collect(analysis_ops.len(), emitted_bridges); let (mut guards, num_vars) = collect_guards_and_vars(analysis_inputargs, analysis_ops); // An inlined bridge branches back into the owner with wasm `br`. The @@ -2580,6 +2616,7 @@ pub fn build_wasm_module( &analysis_ops, ca.emit_ca, &label_resume.captured_refs, + ®ion_spans, ); let num_ref_homes = ref_homes.len(); let shortage = if num_ref_homes > frame.ordinary_home_slots() { @@ -3094,8 +3131,11 @@ fn build_function( ) })?; - // Def / last-use positions for the post-collection Ref reload filter. - let liveness = HomeLiveness::collect(inputargs, ops); + // Def / last-use positions for the post-collection Ref reload filter. The + // spans must match the ones `RefHomes` was built from, or a home would be + // reserved and never reloaded (or the reverse). + let region_spans = InlinedRegionSpan::collect(ops.len(), inlined_bridges); + let liveness = HomeLiveness::collect_with_regions(inputargs, ops, ®ion_spans); // `LOAD_FROM_GC_TABLE` is the backend form of a ConstPtr. Native PyPy // keeps such loop-invariant references in their allocated location across From 2bc94385323236bfab711befd59a0b853e563a16 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 12:24:02 +0900 Subject: [PATCH 5/6] majit: name the guard-jump patch the wasm bridge cell array stands in for `patch_jump_for_descr` rewrites a guard's own jump once a bridge is attached to it. A wasm module is immutable after compilation, so the guard reads a table slot out of a mutable cell array instead, which is why those writes are retained and replayed across a re-emission. The field doc described the replay without naming what it replaces. Assisted-by: Claude --- majit/majit-backend-wasm/src/failguard.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/majit/majit-backend-wasm/src/failguard.rs b/majit/majit-backend-wasm/src/failguard.rs index 8d1df06ba69..6208905d378 100644 --- a/majit/majit-backend-wasm/src/failguard.rs +++ b/majit/majit-backend-wasm/src/failguard.rs @@ -671,8 +671,11 @@ pub struct CompiledWasmLoop { /// onto it. A re-emission retains the old array for an already-running /// module before switching its baked base to a new array. pub _bridge_owned_cells: RefCell>>, - /// Direct-loop guard index to bridge table slot. A re-emission replays - /// these slots into its fresh loop cell array. + /// Direct-loop guard index to bridge table slot. `patch_jump_for_descr` + /// rewrites the guard's own jump to reach a newly attached bridge; a wasm + /// module is immutable once compiled, so the branch instead reads a slot + /// out of a mutable cell array, and these are the writes a re-emission has + /// to replay into its fresh array. pub bridge_slots: RefCell>, /// The same, for a guard that lives inside a trace chained onto this loop, /// keyed by `(owning trace_id, per-trace fail index)`. A standalone chained From 69b97487ea915a8038216f01c3677cd3f090bf1f Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 12:24:11 +0900 Subject: [PATCH 6/6] majit: rebase an inlined region's constant-pool keys with its value ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend constant pool is keyed by value position for a folded value that has no producing op — `sanitize_backend_constants_for_ops` retains exactly the positions no live operation produces — and it is registered per compile (`Backend::set_constants_pool` names the next `compile_loop` / `compile_bridge`). Each trace therefore numbers its own keys, and rebasing a region's value ids moved its reads off the entries that describe them: `unbound_pool_const_seeds` then either declines a resolvable value or, where another trace left a key at the shifted id, seeds it with that value's bits. Each region now carries the pool of its own trace, and the merge replays that pool's `0..width` key window at the region's rebase offset, removing a key another trace left inside the window. Keys outside the window are left alone: they belong to whichever compile registered the pool this module was handed, and rewriting them overwrites the entries the owner's own operations read. Assisted-by: Claude --- majit/majit-backend-wasm/src/codegen.rs | 31 +++++++++++++++++++ majit/majit-backend-wasm/src/lib.rs | 1 + .../majit-backend-wasm/tests/codegen_test.rs | 2 ++ 3 files changed, 34 insertions(+) diff --git a/majit/majit-backend-wasm/src/codegen.rs b/majit/majit-backend-wasm/src/codegen.rs index 935d727f8a6..bf249e32204 100644 --- a/majit/majit-backend-wasm/src/codegen.rs +++ b/majit/majit-backend-wasm/src/codegen.rs @@ -2266,6 +2266,11 @@ pub struct InlinedBridge { /// Base of this already-interned region's GC table. Each region retains /// its own roots; codegen selects it by the LoadFromGcTable producer. pub gc_table_base: u32, + /// The constant pool registered for this region's own trace. A pool is + /// per-trace (`Backend::set_constants_pool` names the next compile), and + /// its value-id keys — the folded values that have no producing op — are + /// in that trace's numbering, so the merge rebases them with the region. + pub constants: indexmap::IndexMap, } /// Whether the exact operation stream emitted for `inputs` has a local loop @@ -2291,6 +2296,7 @@ impl Clone for InlinedBridge { .collect(), ops: self.ops.clone(), gc_table_base: self.gc_table_base, + constants: self.constants.clone(), } } } @@ -2424,6 +2430,7 @@ fn rebase_region_value_ids(bridge: &InlinedBridge, offset: u32) -> (InlinedBridg inputargs, ops, gc_table_base: bridge.gc_table_base, + constants: bridge.constants.clone(), }, width, ) @@ -2468,6 +2475,7 @@ pub fn build_wasm_module( let mut merged_ops = Vec::new(); let mut gc_table_bases = HashMap::new(); let mut rebased_bridges: Vec = Vec::new(); + let mut rebased_constants = indexmap::IndexMap::new(); let (analysis_inputargs, analysis_ops): (&[InputArg], &[Op]) = if inlined_bridges.is_empty() { (inputargs, ops) } else { @@ -2475,9 +2483,27 @@ pub fn build_wasm_module( merged_ops.extend(ops.iter().cloned()); // The merged stream has one local namespace, so every region has to be // moved off the ids the owner and the earlier regions already use. + rebased_constants = constants.clone(); let mut next_value_id = value_id_end(inputargs, ops); for bridge in inlined_bridges { let (bridge, width) = rebase_region_value_ids(bridge, next_value_id); + // The pool is keyed by value position for a folded value with no + // producing op, so rebasing the region's ids moved its reads off + // its own entries. Replay that window at the offset, and drop a + // key another trace left inside it, or `unbound_pool_const_seeds` + // either declines a resolvable value or seeds an unrelated one's + // bits. Keys outside the window are left alone: rewriting them + // would overwrite the entries the owner's own operations read. + for id in 0..width { + match bridge.constants.get(&id) { + Some(&bits) => { + rebased_constants.insert(id + next_value_id, bits); + } + None => { + rebased_constants.shift_remove(&(id + next_value_id)); + } + } + } next_value_id += width; merged_inputargs.extend(bridge.inputargs.iter().map(InputArg::fresh_value_copy)); for op in &bridge.ops { @@ -2498,6 +2524,11 @@ pub fn build_wasm_module( &rebased_bridges }; let region_spans = InlinedRegionSpan::collect(analysis_ops.len(), emitted_bridges); + let constants = if inlined_bridges.is_empty() { + constants + } else { + &rebased_constants + }; let (mut guards, num_vars) = collect_guards_and_vars(analysis_inputargs, analysis_ops); // An inlined bridge branches back into the owner with wasm `br`. The diff --git a/majit/majit-backend-wasm/src/lib.rs b/majit/majit-backend-wasm/src/lib.rs index b132099ee41..60a09a7559c 100644 --- a/majit/majit-backend-wasm/src/lib.rs +++ b/majit/majit-backend-wasm/src/lib.rs @@ -3339,6 +3339,7 @@ impl majit_backend::Backend for WasmBackend { inputargs: inputargs.iter().map(InputArg::fresh_value_copy).collect(), ops: ops_owned.clone(), gc_table_base, + constants: self.constants.clone(), }); let mut merged_ops = candidate.ops.clone(); for region in &candidate.inlined_bridges { diff --git a/majit/majit-backend-wasm/tests/codegen_test.rs b/majit/majit-backend-wasm/tests/codegen_test.rs index deb2e31035f..9e7d8842011 100644 --- a/majit/majit-backend-wasm/tests/codegen_test.rs +++ b/majit/majit-backend-wasm/tests/codegen_test.rs @@ -1276,6 +1276,7 @@ fn inlined_bridge_without_owner_loop_label_declines() { inputargs: vec![InputArg::from_type(Type::Int, 1)], ops: vec![Op::new(OpCode::Finish, &[])], gc_table_base: 0, + constants: indexmap::IndexMap::new(), }], constants: indexmap::IndexMap::new(), vtable_offset: Some(0), @@ -1392,6 +1393,7 @@ fn inlined_bridge_emission_is_independent_of_the_regions_own_numbering() { ], ops: region_ops, gc_table_base: 0, + constants: indexmap::IndexMap::new(), }], constants: indexmap::IndexMap::new(), vtable_offset: Some(0),