Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/6844-region-i32-chains.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion crates/perry-codegen/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
125 changes: 124 additions & 1 deletion crates/perry-codegen/src/stmt/masked_window_region.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u32> {
let empty = std::collections::HashSet::new();
let mut written: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut disqualified: std::collections::HashSet<u32> = 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);
}
_ => {}
}
}
Comment on lines +255 to +276

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recursively inspect writes before promoting an i32 slot.

This scan only sees top-level LocalSet/Update statements. A nested assignment or update inside an RHS is ignored, so a fractional/full-f64 write can leave as_i32 enabled and later reads can use a truncated i32 shadow. Traverse expression children, as the existing strict-write collector does.

Proposed fix
 fn region_i32_bounded_write_locals(stmts: &[Stmt]) -> std::collections::HashSet<u32> {
     let empty = std::collections::HashSet::new();
     let mut written = std::collections::HashSet::new();
     let mut disqualified = std::collections::HashSet::new();

+    fn visit_expr(
+        expr: &Expr,
+        empty: &std::collections::HashSet<u32>,
+        written: &mut std::collections::HashSet<u32>,
+        disqualified: &mut std::collections::HashSet<u32>,
+    ) {
+        match expr {
+            Expr::LocalSet(id, value) => {
+                written.insert(*id);
+                if !crate::collectors::is_strictly_i32_bounded_expr(
+                    value, empty, empty, empty, empty, &mut |_| {},
+                ) {
+                    disqualified.insert(*id);
+                }
+                visit_expr(value, empty, written, disqualified);
+            }
+            Expr::Update { id, .. } => {
+                disqualified.insert(*id);
+            }
+            _ => perry_hir::walker::walk_expr_children(expr, &mut |child| {
+                visit_expr(child, empty, written, disqualified);
+            }),
+        }
+    }
+
     for stmt in stmts {
-        match stmt {
-            Stmt::Expr(Expr::LocalSet(id, value)) => { /* ... */ }
-            Stmt::Expr(Expr::Update { id, .. }) => { /* ... */ }
-            _ => {}
+        if let Stmt::Expr(expr) = stmt {
+            visit_expr(expr, &empty, &mut written, &mut disqualified);
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}
_ => {}
}
}
fn visit_expr(
expr: &Expr,
empty: &std::collections::HashSet<u32>,
written: &mut std::collections::HashSet<u32>,
disqualified: &mut std::collections::HashSet<u32>,
) {
match expr {
Expr::LocalSet(id, value) => {
written.insert(*id);
if !crate::collectors::is_strictly_i32_bounded_expr(
value, empty, empty, empty, empty, &mut |_| {},
) {
disqualified.insert(*id);
}
visit_expr(value, empty, written, disqualified);
}
Expr::Update { id, .. } => {
disqualified.insert(*id);
}
_ => perry_hir::walker::walk_expr_children(expr, &mut |child| {
visit_expr(child, empty, written, disqualified);
}),
}
}
for stmt in stmts {
if let Stmt::Expr(expr) = stmt {
visit_expr(expr, &empty, &mut written, &mut disqualified);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/stmt/masked_window_region.rs` around lines 255 -
276, Update the write scan in the masked-window promotion logic to recursively
traverse expression children, matching the traversal used by the existing
strict-write collector. Ensure nested LocalSet and Update operations are added
to written/disqualified using the same strict i32-bounded validation, so any
nested non-strict write prevents as_i32 promotion.

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.
Expand Down Expand Up @@ -374,13 +424,15 @@ 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) {
refinements.push(RegionRefinement {
stmt_offset: offset,
local_id: *id,
set_number: false,
as_i32: false,
});
}
}
Expand All @@ -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<u32> = 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,
Expand All @@ -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
Expand All @@ -432,6 +507,12 @@ fn lower_region_copy(
let mut privatized: Vec<(u32, String)> = Vec::new();
let mut saved: Vec<(u32, Option<perry_hir::types::Type>)> = Vec::new();
let mut saved_ids: std::collections::HashSet<u32> = 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<u32> = Vec::new();
let mut result = Ok(());
'stmts: for (offset, stmt) in region_stmts.iter().enumerate() {
result = lower_stmt(ctx, stmt);
Expand Down Expand Up @@ -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, &current, 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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -659,6 +767,10 @@ pub(super) fn lower_masked_window_region(
emit_shadow_clears,
&region.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);
Expand Down Expand Up @@ -687,6 +799,9 @@ pub(super) fn lower_masked_window_region(
emit_shadow_clears,
&region.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);
Expand All @@ -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);
}
Expand Down
103 changes: 103 additions & 0 deletions test-files/test_gap_region_masked_window_i32_chain.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading