From 0ce7f0bdc3b6d1feceaf76490fdaa2404c637f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 10:10:42 +0200 Subject: [PATCH 1/2] =?UTF-8?q?perf(codegen):=20#6794=20follow-up=20(a)=20?= =?UTF-8?q?=E2=80=94=20region-scoped=20i32=20chains=20in=20the=20ta=5Fi32?= =?UTF-8?q?=20masked-window=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The straight-line masked-window region versioner (masked_window_region.rs) refines untyped-init numeric locals to Type::Number (f64) in every fast copy. bcryptjs _encipher inits its Feistel halves from a dynamic read (l = lr[off]), which is_strictly_i32_bounded_expr rejects, so l/r never earn a static i32 slot and every l>>>24 / l&0xff in the round pays a branchless ToInt32 tower. In the ta_i32 fast copy — where the masked array reads are already native i32 — bind such locals to a region-scoped i32 shadow slot (i32_counter_slots) at their refinement point, so the whole >>>/&/^/|0 bit-mixing chain lowers to native i32 (tower-free) and the double slot is maintained by every write via the existing LocalSet i32 path. The slot is removed at copy end so the plain_f64 / slow copies (which share ctx.i32_counter_slots and whose reads are f64) are unaffected. A local qualifies only if EVERY in-region LocalSet to it is strictly-i32-bounded (bitwise / |0 / Math.imul — a true signed i32; empty oracle, so copies are conservatively excluded) AND it is never un-refined, so every write maintains the slot and the value is always an in-range i32. Disabled for the plain_f64 tier, where an i32 slot would be maintained by no write. Unoptimized ta_i32 copy for the Blowfish-F shape drops from 186 ToInt32-tower selects to 0. LLVM -O already folds ~74% of those towers on its own; removing the residual is worth ~11% on bcryptjs.compareSync (Int32Array S-boxes -> ta_i32 tier). New gap test covers the round shape, overflow-wrapping, post-region full-Number reads, an un-refine on a fractional write, and the plain-Array tier — all byte-identical to Node. --- crates/perry-codegen/src/collectors/mod.rs | 5 +- .../src/stmt/masked_window_region.rs | 125 +++++++++++++++++- ...test_gap_region_masked_window_i32_chain.ts | 103 +++++++++++++++ 3 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_region_masked_window_i32_chain.ts diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index b6618a15ea..cb76e43f07 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -42,7 +42,10 @@ pub(crate) use escape_arrays::{const_index, MAX_SCALAR_OBJECT_FIELDS}; pub(crate) use escape_check::{check_escapes_in_stmts, find_new_candidates}; pub(crate) use escape_news::MAX_SCALAR_ARRAY_LEN; pub(crate) use hir_facts::{collect_native_region_fact_graph, NativeRegionFactGraph}; -pub(crate) use i32_locals::{collect_integer_let_ids, collect_localset_ids_in_stmts, is_ushr_zero}; +pub(crate) use i32_locals::{ + collect_integer_let_ids, collect_localset_ids_in_stmts, is_strictly_i32_bounded_expr, + is_ushr_zero, +}; pub(crate) use integer_locals::{ collect_flat_row_aliases, is_int32_producing_expr, static_index_window, }; diff --git a/crates/perry-codegen/src/stmt/masked_window_region.rs b/crates/perry-codegen/src/stmt/masked_window_region.rs index fd2e5fb80c..a298e9630d 100644 --- a/crates/perry-codegen/src/stmt/masked_window_region.rs +++ b/crates/perry-codegen/src/stmt/masked_window_region.rs @@ -78,6 +78,16 @@ pub(super) struct RegionRefinement { /// `true` → set `Type::Number`; `false` → restore the original type (the /// local was reassigned a value we can no longer prove numeric). pub set_number: bool, + /// #6794 follow-up (a): when `true`, the ta_i32 fast copy ALSO binds + /// `local_id` to a region-scoped i32 shadow slot (`i32_counter_slots`) at + /// this point, so a `>>>`/`&`/`^` chain on an untyped-init local stays in + /// native i32 instead of paying a branchless ToInt32 tower per op (the + /// bcryptjs `_encipher` residual: `l/r` init from a dynamic `lr[off]` read, + /// so they never earn a static i32 slot). Only set on `set_number` + /// refinements whose local has EVERY in-region write strictly-i32-bounded + /// (per `is_strictly_i32_bounded_expr`) and is never un-refined — so every + /// write maintains the slot and the value is always a true signed i32. + pub as_i32: bool, } pub(super) struct MaskedWindowRegion { @@ -228,6 +238,46 @@ fn expr_is_number_under( } } +/// #6794 follow-up (a): region locals eligible for i32-slot refinement — a +/// `LocalSet` target whose EVERY in-region write is strictly-i32-bounded (a +/// bitwise / `| 0` / `Math.imul` result, i.e. a true signed i32) and that is +/// never an `Update` target (`x++` is full-f64 ToNumeric, not a mod-2^32 wrap). +/// +/// Uses EMPTY oracle / const sets: `is_strictly_i32_bounded_expr`'s bitwise and +/// `| 0` arms are self-contained (they don't consult any of those sets), so this +/// soundly admits the Blowfish round shape (`l = (l ^ P[k]) | 0`, +/// `r = ((… S[…] …) ^ …) | 0`) while conservatively dropping copy-shaped writes +/// (`l = r`, which would need the function-wide i32-ranged oracle). +fn region_i32_bounded_write_locals(stmts: &[Stmt]) -> std::collections::HashSet { + let empty = std::collections::HashSet::new(); + let mut written: std::collections::HashSet = std::collections::HashSet::new(); + let mut disqualified: std::collections::HashSet = std::collections::HashSet::new(); + for stmt in stmts { + match stmt { + Stmt::Expr(Expr::LocalSet(id, value)) => { + written.insert(*id); + let strict = crate::collectors::is_strictly_i32_bounded_expr( + value, + &empty, + &empty, + &empty, + &empty, + &mut |_| {}, + ); + if !strict { + disqualified.insert(*id); + } + } + Stmt::Expr(Expr::Update { id, .. }) => { + disqualified.insert(*id); + } + _ => {} + } + } + written.retain(|id| !disqualified.contains(id)); + written +} + /// Match a masked-window region starting at `stmts[0]`. Returns `None` when /// the run is too short, tracks no eligible array, or carries fewer than /// [`REGION_MIN_TRACKED_READS`] tracked reads. @@ -374,6 +424,7 @@ pub(super) fn try_match_masked_window_region( stmt_offset: offset, local_id: *id, set_number: true, + as_i32: false, }); } } else if refined.remove(id) { @@ -381,6 +432,7 @@ pub(super) fn try_match_masked_window_region( stmt_offset: offset, local_id: *id, set_number: false, + as_i32: false, }); } } @@ -393,6 +445,28 @@ pub(super) fn try_match_masked_window_region( } } + // #6794 follow-up (a): promote a Number refinement to an i32-slot refinement + // when the local's EVERY in-region write is strictly-i32-bounded AND it is + // never un-refined (no `set_number == false` entry). "Never un-refined" + // guarantees every write after the first stays i32-lowerable, so the + // region-scoped i32 shadow slot the ta_i32 copy binds is maintained by every + // write and never reads back a stale value. The un-refined case is left as + // an ordinary Number refinement (f64), preserving today's behaviour. + let i32_write_locals = region_i32_bounded_write_locals(&stmts[..len]); + let un_refined: std::collections::HashSet = refinements + .iter() + .filter(|r| !r.set_number) + .map(|r| r.local_id) + .collect(); + for refinement in refinements.iter_mut() { + if refinement.set_number + && i32_write_locals.contains(&refinement.local_id) + && !un_refined.contains(&refinement.local_id) + { + refinement.as_i32 = true; + } + } + Some(MaskedWindowRegion { len, arrays, @@ -414,6 +488,7 @@ fn lower_region_copy( emit_shadow_clears: bool, refinements: &[RegionRefinement], privatize: bool, + enable_i32: bool, ) -> Result<()> { // Locals refined to Number and never un-refined for the rest of the // region. When `privatize` holds (no enclosing `try` — an exception @@ -432,6 +507,12 @@ fn lower_region_copy( let mut privatized: Vec<(u32, String)> = Vec::new(); let mut saved: Vec<(u32, Option)> = Vec::new(); let mut saved_ids: std::collections::HashSet = std::collections::HashSet::new(); + // #6794 follow-up (a): region-scoped i32 shadow slots this copy bound into + // `ctx.i32_counter_slots` (ta_i32 copy only). Removed at copy end so the + // plain_f64 / slow copies — which share `ctx.i32_counter_slots` — never see + // an untyped-array masked read as an i32 source (their reads are f64, so a + // leaked slot would be maintained by no write and read back stale). + let mut bound_i32: Vec = Vec::new(); let mut result = Ok(()); 'stmts: for (offset, stmt) in region_stmts.iter().enumerate() { result = lower_stmt(ctx, stmt); @@ -483,6 +564,26 @@ fn lower_region_copy( privatized.push((id, original_slot)); } } + // #6794 follow-up (a): bind a region-scoped i32 shadow slot so + // this local's `>>>`/`&`/`^`/`| 0` chain lowers to native i32 + // (tower-free) for the rest of the ta_i32 copy — the + // disqualified-init (`l = lr[off]`) locals never earn one + // statically. Seed it from the just-written value, which is a + // true signed i32 (every in-region write is strictly-i32-bounded, + // guaranteed by `as_i32`), so `fptosi` is exact. `LocalSet`'s i32 + // path (`literals_vars.rs`) then maintains BOTH the i32 slot and + // the double shadow on every later write, so the double slot the + // slot is dropped back to at copy end stays correct. + if enable_i32 && refinements[r].as_i32 && !ctx.i32_counter_slots.contains_key(&id) { + if let Some(slot) = ctx.locals.get(&id).cloned() { + let i32_slot = ctx.func.alloca_entry(I32); + let current = ctx.block().load(DOUBLE, &slot); + let as_i32 = ctx.block().fptosi(DOUBLE, ¤t, I32); + ctx.block().store(I32, &as_i32, &i32_slot); + ctx.i32_counter_slots.insert(id, i32_slot); + bound_i32.push(id); + } + } } else { // Restore the pre-region type for the rest of this copy. match saved.iter().find(|(saved_id, _)| *saved_id == id) { @@ -524,6 +625,13 @@ fn lower_region_copy( } ctx.locals.insert(*id, original_slot.clone()); } + // #6794 follow-up (a): drop this copy's region-scoped i32 shadow slots so + // the plain_f64 / slow copies (which share `ctx.i32_counter_slots`) fall back + // to their own lowering. Every write maintained the double slot, so + // post-region reads read the correct value there. + for id in &bound_i32 { + ctx.i32_counter_slots.remove(id); + } // Drop any still-active suppressions before leaving the copy — the slow // copy and post-region code use the ordinary shadow protocol. for (id, _) in &saved { @@ -659,6 +767,10 @@ pub(super) fn lower_masked_window_region( emit_shadow_clears, ®ion.refinements, privatize, + // ta_i32 copy: masked reads are native i32, so bind region-scoped i32 + // shadow slots and keep the whole bit-mixing chain out of the ToInt32 + // towers (#6794 follow-up (a)). + true, )?; ctx.masked_window_array_facts .retain(|fact| fact.scope_id != ta_scope_id); @@ -687,6 +799,9 @@ pub(super) fn lower_masked_window_region( emit_shadow_clears, ®ion.refinements, privatize, + // plain_f64 copy: masked reads are f64, so an i32 shadow slot would be + // maintained by no write — keep the ordinary Number lowering here. + false, )?; ctx.masked_window_array_facts .retain(|fact| fact.scope_id != plain_scope_id); @@ -696,7 +811,15 @@ pub(super) fn lower_masked_window_region( // Slow copy: the untouched per-access lowering, original static types. ctx.current_block = slow_pre_idx; - lower_region_copy(ctx, region_stmts, base_idx, emit_shadow_clears, &[], false)?; + lower_region_copy( + ctx, + region_stmts, + base_idx, + emit_shadow_clears, + &[], + false, + false, + )?; if !ctx.block().is_terminated() { ctx.block().br(&merge_label); } diff --git a/test-files/test_gap_region_masked_window_i32_chain.ts b/test-files/test_gap_region_masked_window_i32_chain.ts new file mode 100644 index 0000000000..d533284262 --- /dev/null +++ b/test-files/test_gap_region_masked_window_i32_chain.ts @@ -0,0 +1,103 @@ +// #6794 follow-up (a): region masked-window i32 chains. The straight-line +// region versioner's ta_i32 fast copy binds locals whose every in-region write +// is strictly-i32-bounded to a region-scoped i32 shadow slot, so a +// `>>>`/`&`/`^`/`| 0` bit-mixing chain on an UNTYPED-init local (the bcryptjs +// `_encipher` shape: `l = lr[off]` is not statically i32-bounded, so `l` never +// earns a static i32 slot) stays in native i32 instead of paying a ToInt32 +// tower per op. Every shape below must produce byte-identical output to Node +// whether the i32 refinement fires or the region deopts. + +// Canonical Blowfish-F round shape on untyped Int32Array params, l/r init from +// a dynamic `lr[off]` read (disqualifies the static i32 slot -> exercises the +// region-scoped one). +function feistel(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; + return (l ^ r) | 0; +} + +// Overflow: two Int32Array elements summed feed a bitwise op — the i32 chain +// must WRAP (mod 2^32), matching `(a + b) | 0`, not saturate. +function overflow(S: any, seed: number): number { + let x = seed | 0; + x = (x ^ S[0]) | 0; + x = (((S[1 + (x & 1)] + S[2 + (x & 1)]) | 0) ^ S[3 + (x & 3)]) | 0; + x = (((S[4 + (x & 1)] + S[5 + (x & 1)]) | 0) ^ S[6 + (x & 3)]) | 0; + x = (((S[7 + (x & 1)] + S[0]) | 0) ^ S[1]) | 0; + x = (x ^ S[2]) | 0; + return x | 0; +} + +// Post-region: the local is read as a full Number (with a fractional add) AND +// as an unsigned int AFTER the region — the double shadow must carry the +// correct signed-i32 value out of the fast copy. +function postRead(S: any, seed: number): string { + let x = seed | 0; + x = (x ^ S[0]) | 0; + x = (((S[1] + S[2]) | 0) ^ S[3]) | 0; + x = (((S[4] + S[5]) | 0) ^ S[6]) | 0; + x = (x ^ S[7]) | 0; + return (x + 0.5).toFixed(1) + "|" + (x >>> 0).toString(16); +} + +// Un-refine: a NON-strict (fractional Mul) write mid-region must keep the local +// off the i32 slot for the whole region, staying byte-exact. +function unrefine(S: any, seed: number): number { + let x = seed | 0; + x = (x ^ S[0]) | 0; + x = (((S[1] + S[2]) | 0) ^ S[3]) | 0; + x = x * 1.5; + x = (((S[4] + S[5]) | 0) ^ S[6]) | 0; + x = (x ^ S[7]) | 0; + return (x + 100000) | 0; +} + +// Plain-Array variant (untyped param that is NOT a typed array): the plain_f64 +// region tier fires, where the i32-slot refinement is deliberately disabled — +// output must still match. +function feistelPlain(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; + return (l ^ r) | 0; +} + +const S = new Int32Array(1024); +for (let i = 0; i < 1024; i++) S[i] = ((i * 2654435761) ^ (i << 28)) | 0; // negatives + near-overflow +const P = new Int32Array(18); +for (let i = 0; i < 18; i++) P[i] = (i * 0x9e3779b1) | 0; +const lr = new Int32Array(2); + +let feAcc = 0 | 0; +let ovAcc = 0 | 0; +let unAcc = 0 | 0; +for (let i = 0; i < 20000; i++) { + lr[0] = feAcc; + lr[1] = i; + feAcc = (feAcc ^ feistel(S, P, lr, 0)) | 0; + ovAcc = (ovAcc ^ overflow(S, i)) | 0; + unAcc = (unAcc ^ unrefine(S, i)) | 0; +} +console.log("feistel=" + feAcc); +console.log("overflow=" + ovAcc); +console.log("unrefine=" + unAcc); +console.log("postRead=" + postRead(S, 0x12345678 | 0)); + +const Sp: number[] = new Array(1024); +for (let i = 0; i < 1024; i++) Sp[i] = ((i * 2654435761) ^ (i << 28)) | 0; +const Pp: number[] = new Array(18); +for (let i = 0; i < 18; i++) Pp[i] = (i * 0x9e3779b1) | 0; +const lrp: number[] = [0, 0]; +let plainAcc = 0 | 0; +for (let i = 0; i < 20000; i++) { + lrp[0] = plainAcc; + lrp[1] = i; + plainAcc = (plainAcc ^ feistelPlain(Sp, Pp, lrp, 0)) | 0; +} +console.log("feistelPlain=" + plainAcc); From 006b308cf478b8409aa89c33251d2070d25285ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 26 Jul 2026 10:11:41 +0200 Subject: [PATCH 2/2] docs(changelog): changeset for #6844 --- changelog.d/6844-region-i32-chains.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/6844-region-i32-chains.md diff --git a/changelog.d/6844-region-i32-chains.md b/changelog.d/6844-region-i32-chains.md new file mode 100644 index 0000000000..b4129c99f9 --- /dev/null +++ b/changelog.d/6844-region-i32-chains.md @@ -0,0 +1,9 @@ +### Changed + +- Region masked-window versioner (`#6794` follow-up): in the `ta_i32` fast copy, + bind locals whose every in-region write is strictly-i32-bounded to a + region-scoped i32 shadow slot, so a `>>>`/`&`/`^`/`| 0` bit-mixing chain on an + untyped-init local (the bcryptjs `_encipher` shape, `l = lr[off]`) stays in + native i32 instead of paying a ToInt32 tower per op. Removes the residual + ToInt32 towers LLVM cannot fold on its own — ~11% on `bcryptjs.compareSync` + with `Int32Array` S-boxes — with no change to the plain-array or slow copies.