From 97b419f5d1de1986175108c7a6379e7b6f48e76d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 11:27:38 +0200 Subject: [PATCH 1/3] =?UTF-8?q?perf(codegen):=20#6794=20follow-up=20(b)=20?= =?UTF-8?q?=E2=80=94=20skip=20redundant=20shadow-slot=20clears=20in=20mask?= =?UTF-8?q?ed-window=20region=20fast=20copies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A masked-window region fast copy flow-refines its numeric locals to Number and suppresses their per-statement shadow updates (`emit_shadow_slot_update_for_expr` returns early for a `masked_region_scalar_locals` member). Once such a local's shadow slot is cleared to 0 at the refinement point, suppression guarantees no later write ever sets it again — so every subsequent per-statement clear (`js_shadow_slot_set(slot, 0)`) is a redundant no-op. Each of those touches a thread-local, i.e. an `_tlv_get_addr` call, which profiling shows is the #1 runtime symbol in bcryptjs `_encipher` (the shadow-frame/slot TLS traffic is the dominant residual after follow-up (a)). Track the slots a copy has already cleared for a suppressed local (`suppressed_cleared_shadow_slots`); `emit_shadow_slot_clear` skips them. Entries are added right after the first clear, removed the instant a local leaves `masked_region_scalar_locals` (un-refine), and the set is dropped at copy end, so only region fast copies are affected and non-region code is byte-for-byte unchanged. Effect on the 16-round `_encipher` shape: ta_i32 fast copy `js_shadow_slot_set` 12 -> 4 (one clear per suppressed slot instead of one per write); whole function 29 -> 21. Sound: the skipped clears re-zero a slot that provably already holds 0, so GC roots are unchanged. Validated byte-for-byte vs Node on region16 / bcryptjs / the i32-overflow / toint32 / typed-array / crypto gap suite, AND under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 (which panics on a missed/stale root). New gap test drives region functions while allocating each iteration so real collections fire with region frames live. --- crates/perry-codegen/src/codegen/closure.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 2 + crates/perry-codegen/src/expr/mod.rs | 11 +++++ crates/perry-codegen/src/expr/shadow_slot.rs | 8 ++++ .../src/stmt/masked_window_region.rs | 11 +++++ test-files/test_gap_region_shadow_clear_gc.ts | 41 +++++++++++++++++++ 8 files changed, 77 insertions(+) create mode 100644 test-files/test_gap_region_shadow_clear_gc.ts 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..100106e345 --- /dev/null +++ b/test-files/test_gap_region_shadow_clear_gc.ts @@ -0,0 +1,41 @@ +// #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: while region functions are on the stack, this +// test allocates heavily so real collections fire mid-run. Output must stay +// byte-identical to Node (and to a no-GC run) — a dropped root would corrupt a +// live pointer or crash under evacuation. + +// bcryptjs `_encipher` shape: untyped Int32Array params, dynamic-init l/r, +// >130 statements so it is NOT inlined (keeps its own shadow frame). +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; + 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 while allocating garbage every iteration (arrays + +// strings) so the nursery fills and GC runs with `encipher` frames live. +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); From 110f96767951c7e033470b10aaec7c81108dd273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 11:28:11 +0200 Subject: [PATCH 2/3] docs(changelog): changeset for #6846 --- changelog.d/6846-region-shadow-clear-dedup.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog.d/6846-region-shadow-clear-dedup.md 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). From 32e5efe206bcc94118f38a7d32062724a2a9f365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 12:29:59 +0200 Subject: [PATCH 3/3] test(gap): full 16-round encipher + accurate comments in region shadow-clear GC test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit on #6846: extend encipher to the full 16-round bcryptjs shape so it is robustly non-inlined (keeps its own shadow frame), and correct the comments — a region-versioned function is alloc-free/call-free by construction, so a GC cannot fire inside its fast copy; the skip's safety is static (suppression keeps the slot 0). The test guards the broader region+GC interaction under real and forced (PERRY_GC_FORCE_EVACUATE) collections, byte-identical to Node via the parity harness. --- test-files/test_gap_region_shadow_clear_gc.ts | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/test-files/test_gap_region_shadow_clear_gc.ts b/test-files/test_gap_region_shadow_clear_gc.ts index 100106e345..79c99114e7 100644 --- a/test-files/test_gap_region_shadow_clear_gc.ts +++ b/test-files/test_gap_region_shadow_clear_gc.ts @@ -1,12 +1,17 @@ // #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: while region functions are on the stack, this -// test allocates heavily so real collections fire mid-run. Output must stay -// byte-identical to Node (and to a no-GC run) — a dropped root would corrupt a -// live pointer or crash under evacuation. +// 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. -// bcryptjs `_encipher` shape: untyped Int32Array params, dynamic-init l/r, -// >130 statements so it is NOT inlined (keeps its own shadow frame). +// 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]; @@ -17,6 +22,17 @@ function encipher(S: any, P: any, lr: any, off: number): number { 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; } @@ -26,8 +42,9 @@ 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 while allocating garbage every iteration (arrays + -// strings) so the nursery fills and GC runs with `encipher` frames live. +// 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++) {