diff --git a/changelog.d/6846-region-shadow-clear-dedup.md b/changelog.d/6846-region-shadow-clear-dedup.md new file mode 100644 index 0000000000..7fc015a4ec --- /dev/null +++ b/changelog.d/6846-region-shadow-clear-dedup.md @@ -0,0 +1,10 @@ +### Changed + +- Region masked-window versioner (`#6794` follow-up (b)): the region fast copies + no longer re-emit a per-statement shadow-slot clear for a numeric local whose + slot was already cleared and whose writes are suppressed — those + `js_shadow_slot_set(slot, 0)` calls are redundant no-ops but each was an + `_tlv_get_addr` thread-local hit (the dominant runtime symbol in bcryptjs + `_encipher`). Cuts the ta_i32 fast copy's shadow-slot clears from one-per-write + to one-per-slot (12→4 on the 16-round `_encipher` shape) with GC roots and + observable behaviour unchanged (validated under forced evacuation). diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 8a2cdb370e..7cb48714b1 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -845,6 +845,7 @@ pub(super) fn compile_closure( packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), masked_region_scalar_locals: std::collections::HashSet::new(), + suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 007792cfb6..27e476e6f4 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -758,6 +758,7 @@ pub(super) fn compile_module_entry( packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), masked_region_scalar_locals: std::collections::HashSet::new(), + suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), @@ -1361,6 +1362,7 @@ pub(super) fn compile_module_entry( packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), masked_region_scalar_locals: std::collections::HashSet::new(), + suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index ecb0a6179c..37f58e19d9 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -553,6 +553,7 @@ pub(super) fn compile_function( packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), masked_region_scalar_locals: std::collections::HashSet::new(), + suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 9a23c9a4f3..2225b96b60 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -471,6 +471,7 @@ pub(super) fn compile_method( packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), masked_region_scalar_locals: std::collections::HashSet::new(), + suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), @@ -1472,6 +1473,7 @@ pub(super) fn compile_static_method( packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), masked_region_scalar_locals: std::collections::HashSet::new(), + suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index bfe54b1625..888497a281 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -682,6 +682,17 @@ pub(crate) struct FnCtx<'a> { /// until the refinement is dropped (`expr::shadow_slot`). pub masked_region_scalar_locals: std::collections::HashSet, + /// #6794 follow-up (b): shadow slots that a masked-window region fast copy + /// has already cleared to 0 for a currently-suppressed local. Because + /// `emit_shadow_slot_update_for_expr` skips every write to a local in + /// `masked_region_scalar_locals`, such a slot provably stays 0 for the rest + /// of the suppression window — so every later per-statement clear of it (the + /// `_tlv_get_addr`-heavy `js_shadow_slot_set(slot, 0)` that dominated + /// bcryptjs `_encipher` profiles) is a redundant no-op. `emit_shadow_slot_clear` + /// skips slots in this set; entries are added right after the first clear and + /// removed the moment the local leaves `masked_region_scalar_locals`. + pub suppressed_cleared_shadow_slots: std::collections::HashSet, + /// #5093: scoped loop-versioning facts for monomorphic class-field loops. /// Pushed only around the FAST clone of `lower_class_field_versioned_for` /// (`stmt/loops.rs`): the loop preheader already proved the receiver's diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index f65d293efe..2d4149dd35 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -60,6 +60,14 @@ pub(crate) fn emit_shadow_slot_clear(ctx: &mut FnCtx<'_>, slot_idx: u32) { if ctx.persistent_shadow_slots.contains(&slot_idx) { return; } + // #6794 follow-up (b): the slot was already cleared to 0 for a currently + // shadow-suppressed masked-window-region local, and suppression blocks every + // subsequent write to it (`emit_shadow_slot_update_for_expr`), so it provably + // still holds 0 — this clear is a redundant `js_shadow_slot_set(slot, 0)` + // (the `_tlv_get_addr` TLS hit that dominated bcryptjs `_encipher`). Skip it. + if ctx.suppressed_cleared_shadow_slots.contains(&slot_idx) { + return; + } ctx.block().call_void( "js_shadow_slot_set", &[(I32, &slot_idx.to_string()), (I64, "0")], diff --git a/crates/perry-codegen/src/stmt/masked_window_region.rs b/crates/perry-codegen/src/stmt/masked_window_region.rs index a298e9630d..7096bcb249 100644 --- a/crates/perry-codegen/src/stmt/masked_window_region.rs +++ b/crates/perry-codegen/src/stmt/masked_window_region.rs @@ -553,6 +553,11 @@ fn lower_region_copy( if !already_cleared { crate::expr::emit_shadow_slot_clear(ctx, slot_idx); } + // #6794 (b): the slot now holds 0 and suppression blocks + // every further write to it, so record it — each later + // per-statement clear of this slot is a redundant + // `js_shadow_slot_set(slot, 0)` TLS hit we now skip. + ctx.suppressed_cleared_shadow_slots.insert(slot_idx); } } if privatize && !unset_ids.contains(&id) { @@ -600,6 +605,9 @@ fn lower_region_copy( // it again. if ctx.masked_region_scalar_locals.remove(&id) { if let Some(slot_idx) = ctx.shadow_slot_map.get(&id).copied() { + // #6794 (b): suppression ended — later clears of this slot + // are real again, so stop skipping them. + ctx.suppressed_cleared_shadow_slots.remove(&slot_idx); if let Some(local_slot) = ctx.locals.get(&id).cloned() { crate::expr::emit_shadow_slot_bind_for_local(ctx, id); let current = ctx.block().load(DOUBLE, &local_slot); @@ -637,6 +645,9 @@ fn lower_region_copy( for (id, _) in &saved { ctx.masked_region_scalar_locals.remove(id); } + // #6794 (b): the redundant-clear skip set is scoped to this copy; drop it so + // the next copy / post-region code emits real clears again. + ctx.suppressed_cleared_shadow_slots.clear(); for (id, original) in saved { match original { Some(original) => { diff --git a/test-files/test_gap_region_shadow_clear_gc.ts b/test-files/test_gap_region_shadow_clear_gc.ts new file mode 100644 index 0000000000..79c99114e7 --- /dev/null +++ b/test-files/test_gap_region_shadow_clear_gc.ts @@ -0,0 +1,58 @@ +// #6794 follow-up (b): the masked-window region fast copies skip redundant +// per-statement shadow-slot clears (a suppressed local's slot provably stays 0). +// This must not lose a GC root. A region-versioned function is alloc-free and +// call-free by construction (that is what makes its body region-matchable), so a +// GC can never fire *inside* its fast copy — the safety of the skip is static +// (suppression blocks every write, so the slot still holds 0). What a runtime +// test CAN guard is the broader interaction: repeatedly entering/leaving region +// functions while the program allocates enough to drive real collections must +// stay byte-identical to Node and survive forced evacuation. The gap harness +// compares this deterministic output against Node. + +// Full 16-round Feistel, bcryptjs `_encipher` shape: untyped Int32Array params, +// dynamic-init l/r, and enough statements that it is never inlined (so it keeps +// its own shadow frame and the region versioner runs on the whole round chain). +function encipher(S: any, P: any, lr: any, off: number): number { + let l = lr[off]; + let r = lr[off + 1]; + l = (l ^ P[0]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[1]) | 0; + l = (l ^ ((((S[(r>>>24)&0xff] + S[256+((r>>>16)&0xff)])|0) ^ S[512+((r>>>8)&0xff)]) + S[768+(r&0xff)]) ^ P[2]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[3]) | 0; + l = (l ^ ((((S[(r>>>24)&0xff] + S[256+((r>>>16)&0xff)])|0) ^ S[512+((r>>>8)&0xff)]) + S[768+(r&0xff)]) ^ P[4]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[5]) | 0; + l = (l ^ ((((S[(r>>>24)&0xff] + S[256+((r>>>16)&0xff)])|0) ^ S[512+((r>>>8)&0xff)]) + S[768+(r&0xff)]) ^ P[6]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[7]) | 0; + l = (l ^ ((((S[(r>>>24)&0xff] + S[256+((r>>>16)&0xff)])|0) ^ S[512+((r>>>8)&0xff)]) + S[768+(r&0xff)]) ^ P[8]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[9]) | 0; + l = (l ^ ((((S[(r>>>24)&0xff] + S[256+((r>>>16)&0xff)])|0) ^ S[512+((r>>>8)&0xff)]) + S[768+(r&0xff)]) ^ P[10]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[11]) | 0; + l = (l ^ ((((S[(r>>>24)&0xff] + S[256+((r>>>16)&0xff)])|0) ^ S[512+((r>>>8)&0xff)]) + S[768+(r&0xff)]) ^ P[12]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[13]) | 0; + l = (l ^ ((((S[(r>>>24)&0xff] + S[256+((r>>>16)&0xff)])|0) ^ S[512+((r>>>8)&0xff)]) + S[768+(r&0xff)]) ^ P[14]) | 0; + r = (r ^ ((((S[(l>>>24)&0xff] + S[256+((l>>>16)&0xff)])|0) ^ S[512+((l>>>8)&0xff)]) + S[768+(l&0xff)]) ^ P[15]) | 0; + l = (l ^ P[16]) | 0; + r = (r ^ P[17]) | 0; + return (l ^ r) | 0; +} + +const S = new Int32Array(1024); +for (let i = 0; i < 1024; i++) S[i] = ((i * 2654435761) ^ (i << 28)) | 0; +const P = new Int32Array(18); +for (let i = 0; i < 18; i++) P[i] = (i * 0x9e3779b1) | 0; +const lr = new Int32Array(2); + +// Drive the region function repeatedly while allocating garbage every iteration +// (arrays + objects + occasional strings) so the nursery fills and real +// collections run across many enter/leave cycles of `encipher`. +let acc = 0 | 0; +let sink = ""; +for (let i = 0; i < 40000; i++) { + lr[0] = acc; + lr[1] = i; + acc = (acc ^ encipher(S, P, lr, 0)) | 0; + const junk = [i, acc, i ^ acc, { a: i, b: acc }]; // heap allocation each iter + if ((i & 4095) === 0) sink = "" + junk[0] + junk[2]; // occasional string build +} +console.log("acc=" + acc); +console.log("sink=" + sink);