From 15378e94d1b09b23057d24c96add5472417a1137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 27 Jul 2026 12:23:02 +0200 Subject: [PATCH 1/3] =?UTF-8?q?perf(codegen):=20representation-selection?= =?UTF-8?q?=20Phase=201=20=E2=80=94=20canonical=20unboxed=20i32=20locals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a local proven I32/U32 (integer_locals incl. int-valued-TA locals, unsigned_i32_locals, gated by the existing needs_i32_slot proof), the i32 slot becomes the CANONICAL AND ONLY storage: no double slot, no dual writes, no shadow-stack GC binding. A boxed double is materialized (sitofp/uitofp) only at genuinely-boxed use sites. Implements Phase 1 of docs/representation-selection-rfc.md. Mechanism (Phase 2 builds on this): - SlotRep lattice seed {Boxed, I32, U32} + FnCtx.local_slot_reps rep map (absent = Boxed); i32_counter_slots stays the single slot registry. - Rep-aware access helpers in expr/slot_rep.rs: canonical_local_i32_slot, load_canonical_local_boxed (materialize at boxed use sites), store_canonical_local_from_double (NaN-safe toint32_wrap entry, keeps the #6898 OOB-undefined trap closed). - All local access routed: LocalGet/LocalSet/Update (literals_vars), WithSet fallback (instance_misc1), SIMD channel writeback, class-capture writeback, loop matcher gates + counter-slot lifecycle (loops.rs; the arr.length-hoist path now reuses a pre-existing slot and only removes what it inserted), guarded dynamic bounds, static i32 bounds. - Eligibility exclusions (stay Boxed): closure-referenced locals, params, async/generator/was_plain_async bodies, module init, boxed/TDZ locals. - Strictness widening (flag-gated): a proven in-window const int-TA view load (let l = P[0] with literal-length Int32Array) now counts as a strict i32 write, the same judgment that already seeds integer_locals. - Env flag PERRY_CANONICAL_I32_LOCALS (default on; 0/off/false restores the parallel-shadow model), keyed into the object cache + key test. PERRY_REPSEL_DEBUG=1 prints each canonical local at compile time. Claude-Session: https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv --- crates/perry-codegen/src/codegen/closure.rs | 17 ++ crates/perry-codegen/src/codegen/entry.rs | 12 ++ crates/perry-codegen/src/codegen/function.rs | 17 ++ crates/perry-codegen/src/codegen/method.rs | 28 +++ .../perry-codegen/src/collectors/hir_facts.rs | 11 + .../src/collectors/i32_locals.rs | 31 ++- .../src/collectors/integer_locals.rs | 4 +- crates/perry-codegen/src/expr/channel.rs | 17 +- crates/perry-codegen/src/expr/helpers.rs | 5 +- .../perry-codegen/src/expr/instance_misc1.rs | 5 + .../perry-codegen/src/expr/literals_vars.rs | 45 +++- crates/perry-codegen/src/expr/mod.rs | 30 +++ crates/perry-codegen/src/expr/slot_rep.rs | 203 ++++++++++++++++++ .../src/lower_call/capture_writeback.rs | 18 +- crates/perry-codegen/src/stmt/let_stmt.rs | 111 ++++++++-- crates/perry-codegen/src/stmt/loops.rs | 77 ++++++- .../src/stmt/masked_window_region.rs | 2 + .../src/commands/compile/object_cache.rs | 12 ++ .../object_cache/object_cache_tests.rs | 2 + test-files/test_gap_repsel_canonical_i32.ts | 139 ++++++++++++ 20 files changed, 742 insertions(+), 44 deletions(-) create mode 100644 crates/perry-codegen/src/expr/slot_rep.rs create mode 100644 test-files/test_gap_repsel_canonical_i32.ts diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index d6ec9491fd..22b2388f22 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -746,6 +746,20 @@ pub(super) fn compile_closure( &cross_module.module_dispatch, ); + // Representation-selection Phase 1 context gate (see codegen/function.rs). + // Async-step closures (CPS-rewritten `async` closures — the rewrite clears + // `is_async`) and generator wrapper funcs route body locals through shared + // cells, so canonical-i32 storage is disallowed there. + let repsel_allows = crate::expr::canonical_i32_locals_enabled() + && !is_async + && !cross_module.async_step_closures.contains(&func_id) + && !cross_module.local_generator_funcs.contains(&func_id); + let repsel_closure_refs = if repsel_allows { + crate::expr::collect_closure_referenced_locals(body) + } else { + std::collections::HashSet::new() + }; + let mut ctx = FnCtx { func: lf, module_slug: crate::expr::native_region_slug(strings.module_prefix()), @@ -849,6 +863,9 @@ pub(super) fn compile_closure( suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), + local_slot_reps: HashMap::new(), + repsel_context_allows_canonical_i32: repsel_allows, + repsel_closure_ref_locals: repsel_closure_refs, i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 3994fb419f..fc065753ee 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -762,6 +762,12 @@ pub(super) fn compile_module_entry( suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), + local_slot_reps: HashMap::new(), + // Representation-selection Phase 1: module-init contexts keep the + // boxed/parallel-shadow model (top-level locals interleave with + // import/init machinery; the win lives in function bodies). + repsel_context_allows_canonical_i32: false, + repsel_closure_ref_locals: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: main_native_facts.index_used_locals(), strictly_i32_bounded_locals: main_native_facts.strictly_i32_bounded_locals(), @@ -1367,6 +1373,12 @@ pub(super) fn compile_module_entry( suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), + local_slot_reps: HashMap::new(), + // Representation-selection Phase 1: module-init contexts keep the + // boxed/parallel-shadow model (top-level locals interleave with + // import/init machinery; the win lives in function bodies). + repsel_context_allows_canonical_i32: false, + repsel_closure_ref_locals: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: init_native_facts.index_used_locals(), strictly_i32_bounded_locals: init_native_facts.strictly_i32_bounded_locals(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 12e95d2750..0c962f3076 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -481,6 +481,20 @@ pub(super) fn compile_function( &cross_module.module_dispatch, ); + // Representation-selection Phase 1: canonical-i32 locals are allowed in + // plain synchronous function bodies only. Async / generator / + // `was_plain_async` bodies route locals through shared cells (the + // async-to-generator transform), which the canonical model must not touch. + let repsel_allows = crate::expr::canonical_i32_locals_enabled() + && !f.is_async + && !f.is_generator + && !f.was_plain_async; + let repsel_closure_refs = if repsel_allows { + crate::expr::collect_closure_referenced_locals(&f.body) + } else { + std::collections::HashSet::new() + }; + let mut ctx = FnCtx { func: lf, module_slug: crate::expr::native_region_slug(strings.module_prefix()), @@ -577,6 +591,9 @@ pub(super) fn compile_function( suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), + local_slot_reps: HashMap::new(), + repsel_context_allows_canonical_i32: repsel_allows, + repsel_closure_ref_locals: repsel_closure_refs, i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index fb4dcba5db..a9fd211062 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -376,6 +376,17 @@ pub(super) fn compile_method( &cross_module.module_dispatch, ); + // Representation-selection Phase 1 context gate (see codegen/function.rs). + let repsel_allows = crate::expr::canonical_i32_locals_enabled() + && !method.is_async + && !method.is_generator + && !method.was_plain_async; + let repsel_closure_refs = if repsel_allows { + crate::expr::collect_closure_referenced_locals(&method.body) + } else { + std::collections::HashSet::new() + }; + let mut ctx = FnCtx { func: lf, module_slug: crate::expr::native_region_slug(strings.module_prefix()), @@ -475,6 +486,9 @@ pub(super) fn compile_method( suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), + local_slot_reps: HashMap::new(), + repsel_context_allows_canonical_i32: repsel_allows, + repsel_closure_ref_locals: repsel_closure_refs, i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), @@ -1375,6 +1389,17 @@ pub(super) fn compile_static_method( &cross_module.module_dispatch, ); + // Representation-selection Phase 1 context gate (see codegen/function.rs). + let repsel_allows = crate::expr::canonical_i32_locals_enabled() + && !f.is_async + && !f.is_generator + && !f.was_plain_async; + let repsel_closure_refs = if repsel_allows { + crate::expr::collect_closure_referenced_locals(&f.body) + } else { + std::collections::HashSet::new() + }; + let mut ctx = FnCtx { func: lf, module_slug: crate::expr::native_region_slug(strings.module_prefix()), @@ -1478,6 +1503,9 @@ pub(super) fn compile_static_method( suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), + local_slot_reps: HashMap::new(), + repsel_context_allows_canonical_i32: repsel_allows, + repsel_closure_ref_locals: repsel_closure_refs, i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 72fa13c954..4384e8dc55 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -345,11 +345,22 @@ pub(crate) fn collect_type_facts( let (array_facts, effect_facts, materialization_hazards) = collect_array_facts(stmts, params, module_globals, binding_types); let index_used_locals = super::index_uses::collect_index_used_locals(stmts); + // Repsel Phase 1: under `PERRY_CANONICAL_I32_LOCALS` (default on), a + // proven in-window const int-typed-array element load counts as a STRICT + // i32 write (`let l = P[0]` with a literal-length `Int32Array` view) — + // the same judgment that already seeds `integer_locals`. Off, the view + // map is empty and the strictness judgment is bit-identical to before. + let strict_int_ta_views = if crate::expr::canonical_i32_locals_enabled() { + super::integer_locals::collect_const_int_ta_views(stmts) + } else { + HashMap::new() + }; let strictly_i32_bounded_locals = super::i32_locals::collect_strictly_i32_bounded_locals( stmts, &integer_locals, flat_const_ids, clamp_fn_ids, + strict_int_ta_views, ); let known_noalias_buffer_locals = collect_known_noalias_buffer_locals(stmts); let non_escaping_news = super::escape_news::collect_non_escaping_news( diff --git a/crates/perry-codegen/src/collectors/i32_locals.rs b/crates/perry-codegen/src/collectors/i32_locals.rs index d5e0fdf02a..43285d5d78 100644 --- a/crates/perry-codegen/src/collectors/i32_locals.rs +++ b/crates/perry-codegen/src/collectors/i32_locals.rs @@ -47,6 +47,7 @@ pub fn is_strictly_i32_bounded_expr( flat_const_ids: &HashSet, flat_row_alias_ids: &HashSet, clamp_fn_ids: &HashSet, + int_ta_views: &HashMap, on_dep: &mut dyn FnMut(u32), ) -> bool { use perry_hir::{BinaryOp, Expr}; @@ -118,11 +119,23 @@ pub fn is_strictly_i32_bounded_expr( } Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => true, Expr::MathImul(_, _) => true, + // Repsel Phase 1 widening (gated by the caller passing a non-empty + // view map, itself behind `PERRY_CANONICAL_I32_LOCALS`): a proven + // in-window element load from a const int-typed-array view is an i32 + // by construction — the element kind fits i32 and the static index + // window is inside the literal length, so no OOB `undefined` and no + // out-of-range value can appear. This is the same judgment that + // seeds `integer_locals` (`collect_int_ta_load_let_ids`); admitting + // it as a STRICT write lets `let l = P[0]; l = (l ^ P[i & 7]) | 0` + // qualify for the canonical-i32 slot. Expr::IndexGet { object, .. } => match object.as_ref() { Expr::IndexGet { object: inner, .. } => { matches!(inner.as_ref(), Expr::LocalGet(id) if flat_const_ids.contains(id)) } - Expr::LocalGet(id) => flat_row_alias_ids.contains(id), + Expr::LocalGet(id) => { + flat_row_alias_ids.contains(id) + || super::integer_locals::is_proven_int_ta_load(int_ta_views, e) + } _ => false, }, _ => false, @@ -140,6 +153,13 @@ pub struct StrictWriteFacts { pub saw_any: HashSet, pub disqualified: HashSet, copy_deps: HashMap>, + /// INPUT (repsel Phase 1): const int-typed-array views (`id → length`) + /// whose proven in-window element loads are i32 by construction. Carried + /// on the facts struct so the two recursive write-walkers don't need an + /// extra threaded parameter. Empty unless `PERRY_CANONICAL_I32_LOCALS` + /// is on (the caller decides), keeping the flag-off strictness judgment + /// bit-identical to the pre-phase one. + int_ta_views: HashMap, } /// Judge one write to `id` against the oracle and fold the verdict into `out`. @@ -152,7 +172,6 @@ fn record_strict_write( clamp_fn_ids: &HashSet, out: &mut StrictWriteFacts, ) { - out.saw_any.insert(id); let mut deps: Vec = Vec::new(); let strict = is_strictly_i32_bounded_expr( value, @@ -160,8 +179,10 @@ fn record_strict_write( flat_const_ids, flat_row_alias_ids, clamp_fn_ids, + &out.int_ta_views, &mut |d| deps.push(d), ); + out.saw_any.insert(id); if !strict { out.disqualified.insert(id); return; @@ -220,6 +241,7 @@ pub fn collect_strictly_i32_bounded_locals( integer_locals: &HashSet, flat_const_ids: &HashSet, clamp_fn_ids: &HashSet, + int_ta_views: HashMap, ) -> HashSet { let mut flat_row_alias_ids: HashSet = HashSet::new(); collect_flat_row_aliases(stmts, flat_const_ids, &mut flat_row_alias_ids); @@ -227,7 +249,10 @@ pub fn collect_strictly_i32_bounded_locals( // Optimistic seed: assume every integer-valued local is also i32-ranged, // then let the walk record which writes actually prove it and which merely // borrowed the assumption. - let mut out = StrictWriteFacts::default(); + let mut out = StrictWriteFacts { + int_ta_views, + ..StrictWriteFacts::default() + }; walk_writes_for_strict( stmts, integer_locals, diff --git a/crates/perry-codegen/src/collectors/integer_locals.rs b/crates/perry-codegen/src/collectors/integer_locals.rs index 32f7061c9a..8fcb338c8f 100644 --- a/crates/perry-codegen/src/collectors/integer_locals.rs +++ b/crates/perry-codegen/src/collectors/integer_locals.rs @@ -148,7 +148,7 @@ pub(crate) fn static_index_window(e: &perry_hir::Expr) -> Option<(i64, i64)> { /// and the binding is only ever used as an element-access receiver (`S[...]` /// reads and writes) — so nothing can alias it, detach its buffer, or swap /// the value behind it. Returns `id → length`. -fn collect_const_int_ta_views(stmts: &[perry_hir::Stmt]) -> HashMap { +pub(crate) fn collect_const_int_ta_views(stmts: &[perry_hir::Stmt]) -> HashMap { use perry_hir::{Expr, Stmt}; let mut views: HashMap = HashMap::new(); fn seed_stmt(stmt: &Stmt, views: &mut HashMap) { @@ -330,7 +330,7 @@ fn scan_ta_view_escapes_expr(e: &perry_hir::Expr, views: &mut HashMap) /// `S[idx]` where `S` is a tracked const int-typed-array view and `idx`'s /// static window is inside `[0, length)` — an integer by construction. -fn is_proven_int_ta_load(views: &HashMap, e: &perry_hir::Expr) -> bool { +pub(crate) fn is_proven_int_ta_load(views: &HashMap, e: &perry_hir::Expr) -> bool { use perry_hir::Expr; let Expr::IndexGet { object, index } = e else { return false; diff --git a/crates/perry-codegen/src/expr/channel.rs b/crates/perry-codegen/src/expr/channel.rs index 18760b304e..5450588070 100644 --- a/crates/perry-codegen/src/expr/channel.rs +++ b/crates/perry-codegen/src/expr/channel.rs @@ -449,17 +449,18 @@ pub(crate) fn lower_channel_reduction(ctx: &mut FnCtx<'_>, r: &ChannelReduction) )); // Extract per-lane and store back. Mirror writes to both the i32 // and double slots so downstream readers see consistent values. + // Repsel Phase 1: a canonical-i32 accumulator has no double slot — + // the i32 store alone is the whole write. for (lane, &acc_id) in r.acc_ids.iter().enumerate() { let i32_slot = ctx .i32_counter_slots .get(&acc_id) .cloned() .ok_or_else(|| anyhow!("acc {} missing i32 slot", acc_id))?; - let dbl_slot = ctx - .locals - .get(&acc_id) - .cloned() - .ok_or_else(|| anyhow!("acc {} missing double slot", acc_id))?; + let dbl_slot = ctx.locals.get(&acc_id).cloned(); + if dbl_slot.is_none() && !ctx.local_slot_reps.contains_key(&acc_id) { + return Err(anyhow!("acc {} missing double slot", acc_id)); + } let blk = ctx.block(); let lane_val = blk.fresh_reg(); blk.emit_raw(format!( @@ -467,8 +468,10 @@ pub(crate) fn lower_channel_reduction(ctx: &mut FnCtx<'_>, r: &ChannelReduction) lane_val, new_acc_vec, lane )); blk.store(I32, &lane_val, &i32_slot); - let dbl_val = blk.sitofp(I32, &lane_val, DOUBLE); - blk.store(DOUBLE, &dbl_val, &dbl_slot); + if let Some(dbl_slot) = dbl_slot { + let dbl_val = blk.sitofp(I32, &lane_val, DOUBLE); + blk.store(DOUBLE, &dbl_val, &dbl_slot); + } } Ok(()) } diff --git a/crates/perry-codegen/src/expr/helpers.rs b/crates/perry-codegen/src/expr/helpers.rs index 5e308f2e48..d2fbe9917d 100644 --- a/crates/perry-codegen/src/expr/helpers.rs +++ b/crates/perry-codegen/src/expr/helpers.rs @@ -45,7 +45,10 @@ pub(crate) fn expr_has_numeric_pointer_free_array_layout(ctx: &FnCtx<'_>, expr: fn local_get_produces_non_pointer_bits_by_dataflow(ctx: &FnCtx<'_>, id: u32) -> bool { (ctx.i32_counter_slots.contains_key(&id) || ctx.integer_locals.contains(&id)) - && ctx.locals.contains_key(&id) + // Repsel Phase 1: a canonical-i32 local has no `ctx.locals` entry — + // its rep-map membership proves plain (non-boxed, non-captured) + // numeric storage just as well. + && (ctx.locals.contains_key(&id) || ctx.local_slot_reps.contains_key(&id)) && !ctx.boxed_vars.contains(&id) && !ctx.closure_captures.contains_key(&id) && !ctx.module_globals.contains_key(&id) diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 62ce39978e..44cf70fee9 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -113,6 +113,11 @@ fn store_prelowered_local(ctx: &mut FnCtx<'_>, id: u32, value: &str) -> Result, expr: &Expr) -> Result { return Ok(blk.bitcast_i64_to_double(&bits)); } } + // Repsel Phase 1: a canonical-i32 local's ONLY storage is the i32 + // slot — materialize the boxed view (`sitofp`/`uitofp`) here, at + // the boxed use site. + if let Some(v) = crate::expr::load_canonical_local_boxed(ctx, *id) { + return Ok(v); + } if let Some(slot) = ctx.locals.get(id).cloned() { // Issue #48: prefer the i32 slot for int32-stable locals so // LLVM can promote the alloca to an i32 SSA value and skip the @@ -544,6 +550,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else { blk.sitofp(I32, &v_i32, DOUBLE) }; + // Repsel Phase 1: a canonical-i32 local has no double slot + // to mirror (the `ctx.locals` lookup below misses) and its + // shadow slot is never bound, so no clear is needed — the + // materialized `v_dbl` above only serves as the assignment + // expression's value (DCE'd when discarded). + let is_canonical = ctx.local_slot_reps.contains_key(id); if let Some(slot) = ctx.locals.get(id).cloned() { ctx.block().store(DOUBLE, &v_dbl, &slot); } else if let Some(global_name) = ctx.module_globals.get(id).cloned() { @@ -551,8 +563,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // GC_STORE_AUDIT(ROOT): module global slot is registered as a mutable GC root. emit_root_nanbox_store_on_block(ctx.block(), &v_dbl, &g_ref); } - if let Some(slot_idx) = ctx.shadow_slot_map.get(id).copied() { - emit_shadow_slot_clear(ctx, slot_idx); + if !is_canonical { + if let Some(slot_idx) = ctx.shadow_slot_map.get(id).copied() { + emit_shadow_slot_clear(ctx, slot_idx); + } } super::record_native_arena_owner_assignment(ctx, *id, value.as_ref()); super::record_int_facts_for_local_set(ctx, *id, value); @@ -609,6 +623,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // object/string/array value). emit_write_barrier(ctx, &box_ptr, &v_bits); } + } else if crate::expr::store_canonical_local_from_double(ctx, *id, &v, Some(value)) { + // Repsel Phase 1: canonical-i32 local — the NaN-safe helper + // stored the value into the (only) i32 slot. No double store, + // no shadow-frame traffic (the slot is never bound: the value + // is a number, never a pointer). } else if let Some(slot) = ctx.locals.get(id).cloned() { ctx.block().store(DOUBLE, &v, &slot); // Gen-GC Phase A sub-phase 3b: mirror pointer-typed @@ -752,6 +771,28 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(if *prefix { new } else { old }); } } + // Repsel Phase 1: canonical-i32 local — the whole update happens + // in the i32 slot (`load` / `add ±1` / `store`), which post-`-O3` + // promotes to a clean `phi i32` induction variable. The boxed + // double views exist only as the expression's value; LLVM DCEs + // them when the update is a statement. `++`/`--` on an unsigned + // (`>>> 0`-written) local never qualifies for a slot (the + // collector disqualifies Update writes), so the rep here is + // always `I32` — materialize with `sitofp`. + if let Some((i32_slot, _rep)) = crate::expr::canonical_local_i32_slot(ctx, *id) { + let blk = ctx.block(); + let old_i32 = blk.load(I32, &i32_slot); + let delta = match op { + UpdateOp::Increment => "1", + UpdateOp::Decrement => "-1", + }; + let new_i32 = blk.add(I32, &old_i32, delta); + blk.store(I32, &new_i32, &i32_slot); + let old = blk.sitofp(I32, &old_i32, DOUBLE); + let new = blk.sitofp(I32, &new_i32, DOUBLE); + super::record_int_facts_for_update(ctx, *id, *op); + return Ok(if *prefix { new } else { old }); + } let (storage, storage_is_root) = if let Some(slot) = ctx.locals.get(id).cloned() { (slot, false) } else if let Some(global_name) = ctx.module_globals.get(id).cloned() { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index d41e0cdaba..e8549a085e 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -126,6 +126,12 @@ pub(crate) use write_barrier::{ mod dispatch; mod record_value; mod shadow_slot; +mod slot_rep; +pub(crate) use slot_rep::{ + canonical_i32_locals_enabled, canonical_local_i32_slot, collect_closure_referenced_locals, + load_canonical_local_boxed, note_canonical_i32_local, store_canonical_local_from_double, + SlotRep, +}; pub(crate) use dispatch::{lower_expr, lower_math_operand}; pub(crate) use shadow_slot::{ @@ -732,6 +738,30 @@ pub(crate) struct FnCtx<'a> { /// i++) arr[i] = expr`. pub i32_counter_slots: std::collections::HashMap, + /// Representation-selection Phase 1 (RFC `docs/representation-selection- + /// rfc.md`): LocalId → selected slot representation. Absent = `Boxed` + /// (double slot in `ctx.locals`, exactly the pre-phase behavior). An + /// `I32`/`U32` entry means the i32 alloca registered in + /// `ctx.i32_counter_slots` is the CANONICAL AND ONLY storage for the + /// local: there is no double slot, no dual writes, and no shadow-stack GC + /// binding — a boxed double is materialized (`sitofp`/`uitofp`) only at + /// genuinely-boxed use sites. See `expr/slot_rep.rs` for the mechanism, + /// eligibility, and the range-soundness audit. + pub local_slot_reps: std::collections::HashMap, + + /// Whether this function context permits canonical-i32 storage selection. + /// False for async / generator / `was_plain_async` bodies (the async-to- + /// generator transform boxes body locals into shared cells) and for module + /// init. Checked at the `Stmt::Let` eligibility site together with the + /// `PERRY_CANONICAL_I32_LOCALS` env gate. + pub repsel_context_allows_canonical_i32: bool, + + /// Locals referenced anywhere inside a nested closure body (including + /// explicit capture lists). Excluded from canonical-i32 selection — the + /// capture machinery stays on the boxed protocol. Empty when + /// `repsel_context_allows_canonical_i32` is false. + pub repsel_closure_ref_locals: std::collections::HashSet, + /// Parallel `i1` slots for ordinary boolean locals that have stayed inside /// the representation-first subset. The generic `double` slot remains as a /// compatibility shadow for existing lowering paths, but typed consumers diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs new file mode 100644 index 0000000000..1ed8e31e66 --- /dev/null +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -0,0 +1,203 @@ +//! Representation-selection Phase 1 (RFC `docs/representation-selection-rfc.md`): +//! canonical unboxed i32 storage for proven-integer locals. +//! +//! ## The structural inversion +//! +//! Before this phase, a proven-integer local had a canonical NaN-boxed `double` +//! slot plus a *parallel* i32 shadow slot kept in sync by dual writes +//! (`needs_i32_slot` in `stmt/let_stmt.rs`). Post-`-O3` the loop-carried value +//! stayed a `phi double` with per-iteration `fptosi`/`sitofp` LLVM could not +//! remove — the canonical representation was still the box. +//! +//! Phase 1 flips it: for a local whose representation is proven `I32` (or +//! `U32`), the i32 slot in `ctx.i32_counter_slots` IS the canonical (and only) +//! storage. No double slot in `ctx.locals`, no dual writes, no shadow-stack GC +//! binding (the value is a number, never a pointer). A boxed double is +//! *materialized* (`sitofp` / `uitofp`) only at a genuinely-boxed use site. +//! +//! ## The mechanism (what Phase 2 builds on) +//! +//! - [`SlotRep`] is the seed of the RFC's representation lattice. `Boxed` is +//! top and always sound; a local absent from `FnCtx::local_slot_reps` is +//! `Boxed` (exactly the pre-phase behavior). +//! - `FnCtx::local_slot_reps: HashMap` maps LocalId → selected +//! representation. Entries are only ever `I32`/`U32`; the alloca they refer +//! to lives in `ctx.i32_counter_slots` (one slot registry for canonical and +//! parallel-shadow slots — a single source of truth). +//! - All local loads/stores route through representation-aware helpers: +//! [`canonical_local_i32_slot`] (rep + slot query), +//! [`load_canonical_local_boxed`] (materialize a boxed double at a boxed use +//! site), [`store_canonical_local_from_double`] (NaN-safe entry conversion +//! into the i32 slot). Reads that want i32 keep loading the slot from +//! `ctx.i32_counter_slots` directly — same as the shadow model. +//! +//! ## Eligibility +//! +//! Decided at the `Stmt::Let` site (`stmt/let_stmt.rs`): the existing proven +//! `needs_i32_slot` gate (`integer_locals` ∪ `unsigned_i32_locals`, restricted +//! to index-used / strictly-i32-bounded / unsigned locals, not boxed, not +//! module-global, init in range), MINUS locals referenced inside any closure +//! body (`repsel_closure_ref_locals` — the capture machinery stays on the boxed +//! protocol), and only in function contexts that allow it +//! (`repsel_context_allows_canonical_i32`: not async, not generator, not +//! `was_plain_async` — the async-to-generator transform boxes body locals). +//! Under-approximation is free: an ineligible local simply keeps today's +//! parallel-shadow (or plain boxed) lowering. +//! +//! ## Range soundness (audited 2026-07-27) +//! +//! - `strictly_i32_bounded_locals`: every write proven i32-range (greatest +//! fixpoint; `++`/`--` disqualifies). Sound for canonical storage. +//! - `unsigned_i32_locals`: every write a top-level `>>> 0`; `++` disqualifies. +//! u32 bit pattern round-trips; ordinary reads materialize with `uitofp`. +//! - `int_valued_ta_locals` (merged into `integer_locals`): every write i32 or +//! a possibly-OOB int-TA read whose every observation is ToInt32-coercing; +//! NaN-safe entry conversion (`toint32_wrap`) keeps OOB `undefined` → 0. +//! - `integer_locals ∩ index_used_locals`: admission accepts `Add/Sub/Mul` +//! chains that can in principle exceed i32 — but under the pre-phase shadow +//! model every `LocalGet` of such a local ALREADY reads the i32 slot +//! (`literals_vars.rs`), so canonical-i32 storage preserves the shipped, +//! byte-exact-validated semantics for exactly the same set of locals. No new +//! overflow surface is introduced; tightening further would drop the loop +//! counters and index chains this phase exists for. +//! +//! Gated by `PERRY_CANONICAL_I32_LOCALS` (default on; `0`/`off`/`false` +//! reverts to the parallel-shadow model — keyed into the object cache). +//! `PERRY_REPSEL_DEBUG=1` prints one line per canonical local at compile time. + +use std::collections::HashSet; + +use super::FnCtx; +use crate::types::{DOUBLE, I32, I64}; + +/// Slot representation for a function-local binding. Seed of the RFC's +/// representation lattice — grows richer reps (F64, Ptr, …) in later phases. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum SlotRep { + /// NaN-boxed double slot in `ctx.locals` — today's default, always sound. + /// Never stored in `local_slot_reps` (absent = Boxed); the variant exists + /// so rep queries return a total answer. + #[allow(dead_code)] + Boxed, + /// Canonical signed-i32 slot in `ctx.i32_counter_slots`; boxed reads + /// materialize with `sitofp`. + I32, + /// Canonical u32-bit-pattern slot in `ctx.i32_counter_slots`; boxed reads + /// materialize with `uitofp` so values above `INT32_MAX` stay observable + /// as unsigned numbers. + U32, +} + +/// `PERRY_CANONICAL_I32_LOCALS` gate. Enabled by default; `=0`/`off`/`false` +/// disables canonical-i32 storage selection, reverting eligible locals to the +/// parallel-shadow model. Mirrors `int_valued_ta_locals::enabled` and is keyed +/// into the object cache (`object_cache.rs`). +pub(crate) fn canonical_i32_locals_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + !matches!( + std::env::var("PERRY_CANONICAL_I32_LOCALS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +fn repsel_debug_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| std::env::var("PERRY_REPSEL_DEBUG").as_deref() == Ok("1")) +} + +/// Compile-time visibility: one stderr line per local that went canonical-i32, +/// plus a process-wide running count. Only under `PERRY_REPSEL_DEBUG=1`. +pub(crate) fn note_canonical_i32_local(ctx: &FnCtx<'_>, id: u32, name: &str, rep: SlotRep) { + if !repsel_debug_enabled() { + return; + } + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNT: AtomicU64 = AtomicU64::new(0); + let n = COUNT.fetch_add(1, Ordering::Relaxed) + 1; + eprintln!( + "repsel: canonical-{rep:?} local '{name}' (id {id}) in {} [{}] (total {n})", + ctx.source_function, ctx.module_slug + ); +} + +/// Rep + i32 slot for a canonical-i32 local; `None` when the local's slot +/// representation is `Boxed` (absent from the rep map). The slot is looked up +/// in `ctx.i32_counter_slots` — the single slot registry shared with the +/// parallel-shadow model; a rep entry without a registered slot is a compiler +/// bug (the Let site inserts both together). +pub(crate) fn canonical_local_i32_slot(ctx: &FnCtx<'_>, id: u32) -> Option<(String, SlotRep)> { + let rep = *ctx.local_slot_reps.get(&id)?; + debug_assert!(!matches!(rep, SlotRep::Boxed), "Boxed rep is never stored"); + let slot = ctx + .i32_counter_slots + .get(&id) + .cloned() + .expect("canonical-i32 local must have a registered i32 slot"); + Some((slot, rep)) +} + +/// Materialize the boxed-double view of a canonical-i32 local at a boxed use +/// site: one `sitofp` (`uitofp` for `U32`). Returns `None` for Boxed locals. +pub(crate) fn load_canonical_local_boxed(ctx: &mut FnCtx<'_>, id: u32) -> Option { + let (slot, rep) = canonical_local_i32_slot(ctx, id)?; + let blk = ctx.block(); + let raw = blk.load(I32, &slot); + Some(match rep { + SlotRep::U32 => blk.uitofp(I32, &raw, DOUBLE), + _ => blk.sitofp(I32, &raw, DOUBLE), + }) +} + +/// Store an already-lowered boxed double into a canonical-i32 local's slot, +/// keeping the NaN-safe entry conversion (the #6898 trap): a possibly-non- +/// finite value (an OOB int-typed-array read is a NaN-boxed `undefined`) must +/// enter the slot as spec `ToInt32` — raw `fptosi` of a NaN is poison on +/// x86-64. `rhs` (when available) lets known-finite writes keep the cheaper +/// `fptosi→i64→trunc`, bit-identical for finite values; pass `None` for +/// values of unknown provenance (always `toint32_wrap`). +/// +/// Returns `true` when the local was canonical and the store was emitted. +pub(crate) fn store_canonical_local_from_double( + ctx: &mut FnCtx<'_>, + id: u32, + value: &str, + rhs: Option<&perry_hir::Expr>, +) -> bool { + let Some((slot, _rep)) = canonical_local_i32_slot(ctx, id) else { + return false; + }; + let known_finite = rhs.is_some_and(|e| super::is_known_finite(ctx, e)); + let v_i32 = if known_finite { + let v_i64 = ctx.block().fptosi(DOUBLE, value, I64); + ctx.block().trunc(I64, &v_i64, I32) + } else { + ctx.block().toint32_wrap(value) + }; + ctx.block().store(I32, &v_i32, &slot); + true +} + +/// Locals referenced (read or written) anywhere inside a nested closure body — +/// including a closure's explicit capture list. Phase 1 keeps every such local +/// on the boxed protocol: closure capture creation snapshots the double slot, +/// and the capture/writeback machinery assumes it exists. Under-approximating +/// eligibility here is free. +pub(crate) fn collect_closure_referenced_locals(stmts: &[perry_hir::Stmt]) -> HashSet { + let mut closures: Vec<(perry_hir::types::FuncId, perry_hir::Expr)> = Vec::new(); + let mut seen: HashSet = HashSet::new(); + crate::collectors::collect_closures_in_stmts(stmts, &mut seen, &mut closures); + let mut out: HashSet = HashSet::new(); + for (_id, closure) in &closures { + if let perry_hir::Expr::Closure { body, captures, .. } = closure { + for c in captures { + out.insert(*c); + } + crate::collectors::collect_ref_ids_in_stmts(body, &mut out); + } + } + out +} diff --git a/crates/perry-codegen/src/lower_call/capture_writeback.rs b/crates/perry-codegen/src/lower_call/capture_writeback.rs index f575e38f0f..111e914d50 100644 --- a/crates/perry-codegen/src/lower_call/capture_writeback.rs +++ b/crates/perry-codegen/src/lower_call/capture_writeback.rs @@ -79,9 +79,13 @@ pub(crate) fn emit_class_capture_writeback( }; // Only write back to locals that are actually in scope (same-function // construction). Cross-module construction has no accessible outer local. - let Some(outer_slot) = ctx.locals.get(&outer_id).cloned() else { + // Repsel Phase 1: a canonical-i32 local is in scope but has no + // `ctx.locals` entry — its write-back goes through the i32 slot below. + let outer_slot = ctx.locals.get(&outer_id).cloned(); + let outer_is_canonical_i32 = ctx.local_slot_reps.contains_key(&outer_id); + if outer_slot.is_none() && !outer_is_canonical_i32 { continue; - }; + } // Read the updated capture value from the instance field. let field_name = ¶m.name; let key_idx = ctx.strings.intern(field_name); @@ -99,12 +103,20 @@ pub(crate) fn emit_class_capture_writeback( // `outer_id` here is the current-scope id (resolved via new_args or // the __perry_cap_ suffix), so boxed_vars / i32_counter_slots lookups // correctly resolve to the current context's tracking structures. - if ctx.boxed_vars.contains(&outer_id) { + if outer_is_canonical_i32 { + // Repsel Phase 1: canonical-i32 outer local — the class ctor's + // write-back enters the (only) i32 slot through the NaN-safe + // ToInt32 conversion. Observably identical to the pre-phase + // model, whose readers preferred the i32 mirror written below. + crate::expr::store_canonical_local_from_double(ctx, outer_id, &val, None); + } else if ctx.boxed_vars.contains(&outer_id) { + let outer_slot = outer_slot.expect("non-canonical write-back has a slot"); let box_dbl = ctx.block().load(DOUBLE, &outer_slot); let box_ptr = ctx.block().bitcast_double_to_i64(&box_dbl); ctx.block() .call_void("js_box_set", &[(I64, &box_ptr), (DOUBLE, &val)]); } else { + let outer_slot = outer_slot.expect("non-canonical write-back has a slot"); ctx.block().store(DOUBLE, &val, &outer_slot); // If this local also has an i32 fast-path slot (counter / integer // local), keep it in sync. Use fptosi→i64→trunc→i32 to handle diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 698e14ed4f..58a87f7f8b 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -310,7 +310,12 @@ pub(crate) fn lower_let( // the canonical write path that maintains every shadow (boxed cell, // i32 mirror, GC shadow slot, closure capture). A redeclaration with // no initializer (`var x;`) keeps the prior value, matching JS. - if ctx.locals.contains_key(&id) { + // + // Repsel Phase 1: a canonical-i32 local has NO `ctx.locals` entry — its + // storage is the i32 slot tracked through `local_slot_reps` — so the + // reuse guard must consider the rep map too, or a redeclaration would + // re-run the allocation path and leave the local with two slots. + if ctx.locals.contains_key(&id) || ctx.local_slot_reps.contains_key(&id) { if let Some(init_expr) = init { // The binding's OWN declaration ends its Temporal Dead Zone: the // reused-slot write below (plain, unchecked) overwrites any TAG_TDZ @@ -1122,6 +1127,91 @@ pub(crate) fn lower_let( } return Ok(()); } + // Int32 eligibility (issue #48 / #436 / repsel Phase 1). Computed BEFORE + // any storage is allocated so the canonical-i32 path can skip the double + // slot entirely. See the block comments below (kept at their historical + // position) for the full gate rationale. + let init_in_i32_range = match init { + Some(perry_hir::Expr::Integer(n)) => i32::try_from(*n).is_ok(), + _ => true, // non-Integer init: writes will always go via i32-coercing paths + }; + let is_unsigned_i32_local = ctx.unsigned_i32_locals.contains(&id); + let i32_safe_local = ctx.index_used_locals.contains(&id) + || ctx.strictly_i32_bounded_locals.contains(&id) + || is_unsigned_i32_local; + let needs_i32_slot = (ctx.integer_locals.contains(&id) || is_unsigned_i32_local) + && i32_safe_local + && init_in_i32_range + && !matches!(refined_ty, perry_hir::types::Type::BigInt) + && !ctx.boxed_vars.contains(&id) + && !ctx.module_globals.contains_key(&id) + && !ctx.i32_counter_slots.contains_key(&id); + + // Representation-selection Phase 1: for an eligible local in a context + // that allows it, the i32 slot IS the canonical (and only) storage — no + // double slot, no dual writes, no shadow-stack GC binding. Excluded (stay + // on the parallel-shadow / boxed model): closure-referenced locals (the + // capture machinery snapshots the double slot), flat-const row aliases + // (array-valued), and async/generator contexts (gated at FnCtx build). + // See `expr/slot_rep.rs` for the mechanism and range-soundness audit. + let canonical_i32 = needs_i32_slot + && ctx.repsel_context_allows_canonical_i32 + && !ctx.repsel_closure_ref_locals.contains(&id) + && !ctx.array_row_aliases.contains_key(&id); + if canonical_i32 { + let rep = if is_unsigned_i32_local { + crate::expr::SlotRep::U32 + } else { + crate::expr::SlotRep::I32 + }; + // Entry-block alloca, zero-initialized: a branch-skipped `Let` (switch + // fallthrough, hoisted `var`) reads 0 — identical to the parallel- + // shadow model, whose reads already preferred the 0-seeded i32 slot. + let i32_slot = ctx.func.alloca_entry(I32); + ctx.func.entry_allocas_push_store(I32, "0", &i32_slot); + ctx.i32_counter_slots.insert(id, i32_slot.clone()); + ctx.local_slot_reps.insert(id, rep); + ctx.local_types.insert(id, refined_ty.clone()); + crate::expr::note_canonical_i32_local(ctx, id, name, rep); + if let Some(init_expr) = init { + let i32_slots = ctx.i32_counter_slots.clone(); + let flat_ca = ctx.flat_const_arrays.clone(); + let ara = ctx.array_row_aliases.clone(); + let int_locals = ctx.integer_locals.clone(); + if crate::expr::can_lower_expr_as_i32( + init_expr, + &i32_slots, + &flat_ca, + &ara, + &int_locals, + ctx.clamp3_functions, + ctx.clamp_u8_functions, + ctx.integer_returning_functions, + ctx.i32_identity_functions, + ) { + // i32-native init: compute directly in i32, single store. + let v_i32 = crate::expr::lower_expr_as_i32(ctx, init_expr)?; + ctx.block().store(I32, &v_i32, &i32_slot); + } else { + // Boxed init entering the i32 slot: NaN-safe conversion (the + // #6898 trap — an OOB int-typed-array read is a NaN-boxed + // `undefined`; raw fptosi of it is poison on x86-64). + let v = lower_expr_with_expected_type(ctx, init_expr, Some(&refined_ty))?; + crate::expr::store_canonical_local_from_double(ctx, id, &v, Some(init_expr)); + } + } + if !mutable { + if let Some(value) = init.and_then(|expr| match expr { + perry_hir::Expr::Integer(value) => Some(*value as f64), + perry_hir::Expr::Number(value) if value.is_finite() => Some(*value), + _ => None, + }) { + ctx.const_number_locals.insert(id, value); + } + } + return Ok(()); + } + // Slot must live in the entry block — see the boxed-var case // above. Putting allocas inside an `if` arm causes verifier // failures the moment a closure in another branch captures @@ -1157,10 +1247,7 @@ pub(crate) fn lower_let( // and silently corrupts every read of the i32 slot. Mutable locals // are always written through paths we control (Update, `(expr) | 0`) // which produce in-range int32 values per JS ToInt32 semantics. - let init_in_i32_range = match init { - Some(perry_hir::Expr::Integer(n)) => i32::try_from(*n).is_ok(), - _ => true, // non-Integer init: writes will always go via i32-coercing paths - }; + // (`init_in_i32_range` is computed once, above the canonical-i32 branch.) // Issue #140 follow-up + #435 fix: gate the Let-site i32 // shadow on `index_used_locals` (with transitive closure — // see `collect_index_used_locals` in collectors.rs). The @@ -1199,17 +1286,9 @@ pub(crate) fn lower_let( // recovers the FNV-1a `h` accumulator and similar // explicit-i32-coerce shapes without reintroducing #435's // accumulator overflow). - let is_unsigned_i32_local = ctx.unsigned_i32_locals.contains(&id); - let i32_safe_local = ctx.index_used_locals.contains(&id) - || ctx.strictly_i32_bounded_locals.contains(&id) - || is_unsigned_i32_local; - let needs_i32_slot = (ctx.integer_locals.contains(&id) || is_unsigned_i32_local) - && i32_safe_local - && init_in_i32_range - && !matches!(refined_ty, perry_hir::types::Type::BigInt) - && !ctx.boxed_vars.contains(&id) - && !ctx.module_globals.contains_key(&id) - && !ctx.i32_counter_slots.contains_key(&id); + // (`needs_i32_slot` and its inputs are computed once, above the + // canonical-i32 branch; when that branch fires this parallel-shadow + // allocation is skipped entirely.) if needs_i32_slot { let i32_slot = ctx.func.alloca_entry(I32); ctx.func.entry_allocas_push_store(I32, "0", &i32_slot); diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index e756e74061..0b27f2bad0 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -355,7 +355,10 @@ fn match_numeric_range_add_loop( } => *id, _ => return None, }; - if !ctx.locals.contains_key(&counter_id) + // Repsel Phase 1: a canonical-i32 counter has no `ctx.locals` entry but + // is fully readable/writable storage (the lowering routes its reads + // through `LocalGet` and stores its final value into the i32 slot). + if !(ctx.locals.contains_key(&counter_id) || ctx.local_slot_reps.contains_key(&counter_id)) || ctx.boxed_vars.contains(&counter_id) || !matches!( update, @@ -428,6 +431,7 @@ fn match_numeric_range_add_loop( Expr::LocalGet(bound_id) if *bound_id != counter_id && (ctx.locals.contains_key(bound_id) + || ctx.local_slot_reps.contains_key(bound_id) || ctx.module_globals.contains_key(bound_id)) && !(ctx.boxed_vars.contains(bound_id) && !ctx.module_globals.contains_key(bound_id)) @@ -732,7 +736,12 @@ fn match_packed_f64_range_loop( if ctx.boxed_vars.contains(bound_id) { return None; } - if !ctx.locals.contains_key(bound_id) && !ctx.module_globals.contains_key(bound_id) { + // Repsel Phase 1: canonical-i32 bounds read through `LocalGet` + // (materialized from the i32 slot) — accessible storage. + if !ctx.locals.contains_key(bound_id) + && !ctx.local_slot_reps.contains_key(bound_id) + && !ctx.module_globals.contains_key(bound_id) + { return None; } if !local_bound_is_loop_invariant(condition?, update, body, *bound_id) { @@ -752,7 +761,10 @@ fn match_packed_f64_range_loop( ) { return None; } - if !ctx.locals.contains_key(&counter_id) + // Repsel Phase 1: canonical-i32 counters qualify — they already own the + // shared i32 slot the versioned copies read, and the `Update`/`LocalGet` + // lowerings maintain it. + if !(ctx.locals.contains_key(&counter_id) || ctx.local_slot_reps.contains_key(&counter_id)) || ctx.boxed_vars.contains(&counter_id) || !ctx.integer_locals.contains(&counter_id) || !loop_counter_bounds_are_safe(ctx, counter_id, update, body) @@ -4093,8 +4105,23 @@ fn emit_guarded_i32_bound( label_prefix: &str, ) -> Option { let bound_slot = ctx.locals.get(&bound_id).cloned()?; - let counter_slot = ctx.locals.get(&counter_id).cloned()?; + // Repsel Phase 1: a canonical-i32 counter has no double slot — only the + // loop-PRIVATE branch below needs one (it seeds from the f64 slot). The + // shared-slot branch never touches the counter's double storage, so a + // canonical counter (whose shared slot always exists) passes through. let shared_counter_i32 = ctx.i32_counter_slots.get(&counter_id).cloned(); + let counter_slot = match ctx.locals.get(&counter_id).cloned() { + Some(slot) => slot, + None if shared_counter_i32.is_some() + && ctx.local_slot_reps.contains_key(&counter_id) => + { + // Unused: the shared branch returns before any counter load. The + // sentinel register name makes any future misuse fail the LLVM + // parser loudly instead of silently emitting an empty operand. + "%repsel_canonical_counter_has_no_f64_slot".to_string() + } + None => return None, + }; let counter_is_private = shared_counter_i32.is_none(); if counter_is_private && !dynamic_bound_private_counter_is_safe(ctx, counter_id, update, body) { return None; @@ -4412,6 +4439,9 @@ fn lower_for_after_init_with_i32_bound( && loop_counter_bounds_are_safe(ctx, hoist.counter_id, update, body) }) }); + // Whether THIS site allocated the counter's i32 slot (vs. the Let site or + // repsel Phase 1 having done so). Only the inserter removes at loop exit. + let mut hoist_counter_i32_was_fresh = false; let hoisted_length_slot: Option = if let Some(hoist) = hoist_classification { let arr_box_loaded = lower_expr( ctx, @@ -4451,7 +4481,17 @@ fn lower_for_after_init_with_i32_bound( // a parallel i32 slot. The Update lowering will keep it in sync, // and IndexGet/IndexSet will load the i32 directly instead of // emitting a `fptosi double → i32` on every iteration. - if ctx.integer_locals.contains(&hoist.counter_id) { + // + // Repsel Phase 1: when the counter ALREADY owns a slot — a + // canonical-i32 counter (whose i32 slot is its only storage) or a + // Let-site parallel shadow — reuse it instead of replacing it, and + // track freshness so loop exit only removes what this site inserted. + // Removing a canonical counter's slot at loop exit would strand the + // local with no storage at all (every write keeps a reused slot in + // sync, so keeping it registered is always valid). + if ctx.integer_locals.contains(&hoist.counter_id) + && !ctx.i32_counter_slots.contains_key(&hoist.counter_id) + { if let Some(counter_slot) = ctx.locals.get(&hoist.counter_id).cloned() { let i32_slot = ctx.func.alloca_entry(I32); // Initialize from the current double value. @@ -4459,6 +4499,7 @@ fn lower_for_after_init_with_i32_bound( let cur_i32 = ctx.block().fptosi(DOUBLE, &cur_dbl, I32); ctx.block().store(I32, &cur_i32, &i32_slot); ctx.i32_counter_slots.insert(hoist.counter_id, i32_slot); + hoist_counter_i32_was_fresh = true; } } @@ -4531,7 +4572,16 @@ fn lower_for_after_init_with_i32_bound( // Hoist `fptosi(n)` to a fresh i32 alloca before the cond block // so LLVM sees a loop-invariant integer bound — critical for // SCEV / LoopVectorizer to recognize the induction variable. - if let Some(bound_slot) = ctx.locals.get(&bound_id).cloned() { + // Repsel Phase 1: a canonical-i32 bound has no double slot — its + // i32 slot already holds the exact value, no conversion needed. + if let Some((bound_i32_slot, _rep)) = + crate::expr::canonical_local_i32_slot(ctx, bound_id) + { + let bound_i32 = ctx.block().load(I32, &bound_i32_slot); + let slot = ctx.func.alloca_entry(I32); + ctx.block().store(I32, &bound_i32, &slot); + Some(slot) + } else if let Some(bound_slot) = ctx.locals.get(&bound_id).cloned() { let bound_dbl = ctx.block().load(DOUBLE, &bound_slot); let bound_i32 = ctx.block().fptosi(DOUBLE, &bound_dbl, I32); let slot = ctx.func.alloca_entry(I32); @@ -4812,9 +4862,14 @@ fn lower_for_after_init_with_i32_bound( ctx.loop_targets.pop(); // Pop the hoisted-length entry so nested loops or sibling loops - // don't see a stale slot. - if let Some(hoist) = hoist_classification { - ctx.i32_counter_slots.remove(&hoist.counter_id); + // don't see a stale slot. Repsel Phase 1: only when THIS site inserted + // it — a canonical-i32 counter's slot is its ONLY storage and must + // survive the loop (a Let-site parallel shadow is likewise maintained + // by every write and stays registered). + if hoist_counter_i32_was_fresh { + if let Some(hoist) = hoist_classification { + ctx.i32_counter_slots.remove(&hoist.counter_id); + } } if let Some(arr_id) = hoisted_length_arr_id { ctx.cached_lengths.remove(&arr_id); @@ -5399,7 +5454,9 @@ pub(crate) fn classify_for_local_bound_dynamic( } fn local_bound_storage_accessible(ctx: &crate::expr::FnCtx<'_>, bound_id: u32) -> bool { - ctx.locals.contains_key(&bound_id) + // Repsel Phase 1: a canonical-i32 bound has no `ctx.locals` entry; its + // i32 slot is directly readable storage (better, even — no conversion). + (ctx.locals.contains_key(&bound_id) || ctx.local_slot_reps.contains_key(&bound_id)) && !ctx.boxed_vars.contains(&bound_id) && !ctx.module_globals.contains_key(&bound_id) } diff --git a/crates/perry-codegen/src/stmt/masked_window_region.rs b/crates/perry-codegen/src/stmt/masked_window_region.rs index 7096bcb249..ae2a5cdcae 100644 --- a/crates/perry-codegen/src/stmt/masked_window_region.rs +++ b/crates/perry-codegen/src/stmt/masked_window_region.rs @@ -250,6 +250,7 @@ fn expr_is_number_under( /// (`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 empty_views = std::collections::HashMap::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 { @@ -262,6 +263,7 @@ fn region_i32_bounded_write_locals(stmts: &[Stmt]) -> std::collections::HashSet< &empty, &empty, &empty, + &empty_views, &mut |_| {}, ); if !strict { diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index fd67f45f63..5f4af42122 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -958,6 +958,18 @@ fn compute_object_cache_key_with_env( "env_int_valued_locals", env_var("PERRY_INT_VALUED_LOCALS").as_deref().unwrap_or(""), ); + // Representation-selection Phase 1 — canonical unboxed i32 locals: + // `=0`/`off`/`false` reverts eligible integer locals from canonical-i32 + // storage (single i32 slot, no double slot, no shadow binding) back to + // the parallel-shadow model (double slot + mirrored i32 writes), which + // changes the emitted IR / .o bytes — a warm cache must not serve an + // object built under the other setting. + h.field( + "env_canonical_i32_locals", + env_var("PERRY_CANONICAL_I32_LOCALS") + .as_deref() + .unwrap_or(""), + ); h.finish() } diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 8a08cb1153..1e33a4d95b 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -615,6 +615,8 @@ fn key_changes_with_codegen_env_vars() { "PERRY_TA_PARAM_F64_READ", // Native-i32 residency for int-typed-array-seeded locals. "PERRY_INT_VALUED_LOCALS", + // Representation-selection Phase 1: canonical unboxed i32 locals. + "PERRY_CANONICAL_I32_LOCALS", ] { // Sample state without the var, with the var, and with a different // value — all three keys must be distinct. diff --git a/test-files/test_gap_repsel_canonical_i32.ts b/test-files/test_gap_repsel_canonical_i32.ts new file mode 100644 index 0000000000..9e253c7745 --- /dev/null +++ b/test-files/test_gap_repsel_canonical_i32.ts @@ -0,0 +1,139 @@ +// Representation-selection Phase 1: canonical unboxed i32 locals +// (PERRY_CANONICAL_I32_LOCALS, RFC docs/representation-selection-rfc.md). +// +// Exercises every seam of the canonical-i32 storage model against Node: +// - an eligible strictly-i32-bounded accumulator whose value flows to boxed +// consumers (console.log, plain-array push after the loop via `| 0`), +// - loop-counter shapes (arr.length hoist, static bound, decrement, +// counter declared outside the loop and read AFTER it), +// - an unsigned `>>> 0` local with a value above 2^31 (uitofp +// materialization), +// - const Int32Array views (the slot.ts mix shape), +// - a possibly-OOB int-typed-array seed (the #6898 NaN-safe ToInt32 trap), +// - hoisted `var` redeclaration of an integer local, +// - update expressions (postfix value) on a canonical counter, +// - a closure-captured integer local (excluded from canonical — must stay +// correct on the boxed protocol). + +// 1. Strictly-bounded FNV-style accumulator -> boxed consumers. +function mixToArray(): string { + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) bytes[i] = (i * 37 + 11) & 0xff; + let h = 0x811c9dc5 | 0; + for (let i = 0; i < bytes.length; i++) { + h = (h ^ bytes[i]) | 0; + h = Math.imul(h, 0x01000193); + } + console.log(h); // bare boxed consumer: sitofp materialization must be exact + const out: number[] = []; + out.push(h | 0); // boxed consumer AFTER the loop through a coercing observation + out.push((h >>> 16) & 0xffff); + return out.join(","); +} +console.log(mixToArray()); + +// 2. Loop counters: hoisted arr.length bound, static bound, decrement, +// and a counter declared OUTSIDE the loop read after it. +function counters(): string { + const arr: number[] = []; + for (let i = 0; i < 10; i++) arr[i] = i * 2; + let sum = 0; + for (let i = 0; i < arr.length; i++) sum = (sum + arr[i]) | 0; + let down = 0; + for (let i = 9; i >= 0; i--) down = (down + arr[i] * (i + 1)) | 0; + let i = 0; + for (; i < arr.length; i++) { + sum = (sum + arr[i]) | 0; // keeps `i` index-used -> canonical-i32 counter + } + return sum + ":" + down + ":" + i; // `i` read after the loop +} +console.log(counters()); + +// 3. Unsigned `>>> 0` locals: seed above 2^31 printed directly (uitofp), and +// a u32 mixing recurrence. +function unsignedMix(): string { + const arr: number[] = []; + let s = 0x9e3779b9 >>> 0; // 2654435769 > 2^31 + if (arr.length !== 0) { + s = 0 >>> 0; // never taken; keeps `s` mutable with all-`>>>0` writes + } + let u = 0x9e3779b9 >>> 0; + for (let k = 0; k < 8; k++) { + u = (u ^ ((k * 0x85ebca6b) | 0)) >>> 0; + u = ((u << 13) | (u >>> 19)) >>> 0; + } + return s + ":" + u + ":" + (u >>> 0); +} +console.log(unsignedMix()); + +// 4. Const Int32Array view mix (the slot.ts shape): proven in-window loads. +function taMix(): number { + const P = new Int32Array(8); + for (let i = 0; i < 8; i++) P[i] = Math.imul(i + 1, 0x9e3779b9 | 0); + let l = P[0]; + for (let i = 0; i < 64; i++) { + l = (l ^ P[i & 7]) | 0; + } + return l | 0; +} +console.log(taMix()); + +// 5. Possibly-OOB int-typed-array seed: ToInt32(undefined) = 0 must hold +// (the NaN-safe entry conversion), with the local index-used like the +// bcryptjs Feistel accumulators. +function feistel(lr: Int32Array, off: number, S: Int32Array): number { + let l = lr[off]; + l ^= S[(l >>> 28) & 7]; + l = (l << 3) | (l >>> 29); + return l | 0; +} +const lr = new Int32Array([0x12345678, 0x0fedcba9 | 0]); +const S = new Int32Array([9, 8, 7, 6, 5, 4, 3, 2]); +console.log(feistel(lr, 0, S), feistel(lr, 1, S), feistel(lr, 9, S)); + +// 6. Hoisted `var` redeclaration of an integer local (shared HIR id). +function redecl(flag: boolean): number { + if (flag) { + var v = 3 | 0; + } else { + var v = 7 | 0; + } + var q = 0; + for (var k = 0; k < 3; k++) q = (q + v) | 0; + return q; +} +console.log(redecl(true), redecl(false)); + +// 7. Update expressions on a canonical counter: postfix returns the OLD value. +function updExpr(): string { + const arr = [10, 20, 30]; + let i = 0; + const first = arr[i++]; + const second = arr[i++]; + const third = arr[--i]; // prefix returns the NEW value + return first + "," + second + "," + third + "," + i; +} +console.log(updExpr()); + +// 8. Closure-captured integer local: excluded from canonical storage, must +// keep exact boxed-protocol behavior. +function captured(): number { + let t = 0; + for (let i = 0; i < 4; i++) t = (t + i * i) | 0; + const f = () => t + 1; + return f(); +} +console.log(captured()); + +// 9. Mixed: canonical accumulator alongside a plain f64 accumulator in the +// same loop (only the strict one flips representation). +function mixedReps(): string { + let ints = 0; + let floats = 0.5; + for (let i = 0; i < 6; i++) { + ints = (ints + i * 3) | 0; + floats = floats + i / 2; + } + return ints + ":" + floats; +} +console.log(mixedReps()); From 7164558b1f8e1dd06e1d6ee27b62ed49614755a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 27 Jul 2026 12:42:48 +0200 Subject: [PATCH 2/3] =?UTF-8?q?perf(codegen):=20repsel=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20int-valued-TA=20locals=20as=20canonical-only=20elig?= =?UTF-8?q?ibility=20term?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box slot.ts mix shape (let l = P[0] from an Int32Array PARAM, bitwise updates, observations only in ToInt32-coercing contexts) is admitted via int_valued_ta_locals (#6898) but is neither index-used nor strictly bounded, so the base i32-safe gate missed it. Retain the int-valued-TA subset as its own fact on RepresentationFacts and accept it as a canonical-only safety term: its whole-function observation proof makes canonical-i32 storage output-invariant under the NaN-safe entry conversion. The parallel-shadow gate (needs_i32_slot) is deliberately NOT widened — flag-off stays exactly the pre-phase model. Plus cargo fmt. Claude-Session: https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv --- .../perry-codegen/src/collectors/hir_facts.rs | 17 ++++ crates/perry-codegen/src/stmt/let_stmt.rs | 18 +++- crates/perry-codegen/src/stmt/loops.rs | 89 +++++++++---------- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 4384e8dc55..9cd9a59f3c 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -39,6 +39,14 @@ pub(crate) struct RepresentationFacts { /// is a non-BigInt expression). Seeds `is_provably_not_bigint`, which gates /// the inline non-BigInt bitwise fast path. See `collect_not_bigint_locals`. pub not_bigint_locals: HashSet, + /// The `int_valued_ta_locals` subset of `integer_locals` (#6898): every + /// write is i32-producing OR a possibly-OOB int-kind typed-array read, and + /// every observation is ToInt32-coercing. Retained separately because that + /// whole-function observation proof makes canonical-i32 storage (repsel + /// Phase 1) output-invariant even when the local is neither index-used nor + /// strictly-i32-bounded — the box `slot.ts` mix shape (`let l = P[0]` from + /// an Int32Array PARAM, bitwise-only updates and observations). + pub int_valued_ta_locals: HashSet, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -129,6 +137,10 @@ impl TypeFacts { &self.representation.unsigned_i32_locals } + pub(crate) fn int_valued_ta_locals(&self) -> &HashSet { + &self.representation.int_valued_ta_locals + } + pub(crate) fn not_bigint_locals(&self) -> &HashSet { &self.representation.not_bigint_locals } @@ -330,12 +342,16 @@ pub(crate) fn collect_type_facts( // `PERRY_INT_VALUED_LOCALS` (keyed into the object cache). Boxed / module- // global locals are excluded (they never take the i32 shadow slot and would // only pollute the fact for other consumers). + let mut int_valued_ta_locals: HashSet = HashSet::new(); if super::int_valued_ta_locals::enabled() { let extra = super::int_valued_ta_locals::collect_int_valued_ta_locals(stmts, params, binding_types); for id in extra { if !boxed_vars.contains(&id) && !module_globals.contains_key(&id) { integer_locals.insert(id); + // Retained as its own fact for repsel Phase 1 eligibility — + // see `RepresentationFacts::int_valued_ta_locals`. + int_valued_ta_locals.insert(id); } } } @@ -407,6 +423,7 @@ pub(crate) fn collect_type_facts( integer_locals: integer_locals.clone(), unsigned_i32_locals, not_bigint_locals, + int_valued_ta_locals, }, arrays: array_facts, effect: effect_facts, diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 58a87f7f8b..2ba7e2622c 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1154,7 +1154,23 @@ pub(crate) fn lower_let( // capture machinery snapshots the double slot), flat-const row aliases // (array-valued), and async/generator contexts (gated at FnCtx build). // See `expr/slot_rep.rs` for the mechanism and range-soundness audit. - let canonical_i32 = needs_i32_slot + // + // Canonical-only safety term: an `int_valued_ta_locals` member (#6898) is + // eligible even when neither index-used nor strictly-i32-bounded — its + // whole-function proof (every write i32-producing or an int-kind TA read, + // every observation ToInt32-coercing) makes canonical-i32 storage + // output-invariant with the NaN-safe entry conversion. The parallel-shadow + // gate (`needs_i32_slot` below) is deliberately NOT widened, so the + // flag-off model stays exactly the pre-phase one. + let canonical_safe_local = + i32_safe_local || ctx.native_facts.int_valued_ta_locals().contains(&id); + let canonical_i32 = (ctx.integer_locals.contains(&id) || is_unsigned_i32_local) + && canonical_safe_local + && init_in_i32_range + && !matches!(refined_ty, perry_hir::types::Type::BigInt) + && !ctx.boxed_vars.contains(&id) + && !ctx.module_globals.contains_key(&id) + && !ctx.i32_counter_slots.contains_key(&id) && ctx.repsel_context_allows_canonical_i32 && !ctx.repsel_closure_ref_locals.contains(&id) && !ctx.array_row_aliases.contains_key(&id); diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 0b27f2bad0..a414ea5abd 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -4112,9 +4112,7 @@ fn emit_guarded_i32_bound( let shared_counter_i32 = ctx.i32_counter_slots.get(&counter_id).cloned(); let counter_slot = match ctx.locals.get(&counter_id).cloned() { Some(slot) => slot, - None if shared_counter_i32.is_some() - && ctx.local_slot_reps.contains_key(&counter_id) => - { + None if shared_counter_i32.is_some() && ctx.local_slot_reps.contains_key(&counter_id) => { // Unused: the shared branch returns before any counter load. The // sentinel register name makes any future misuse fail the LLVM // parser loudly instead of silently emitting an empty operand. @@ -4546,54 +4544,53 @@ fn lower_for_after_init_with_i32_bound( // site having done so already). Only the site that inserted should // remove it at loop exit to avoid disturbing a pre-existing slot. let local_bound_counter_i32_was_fresh: bool; - let i32_local_bound_slot: Option = - if let Some((counter_id, bound_id, _op)) = local_bound_classification { - // Allocate a parallel i32 slot for the counter if not already - // present. Counters that fall outside `integer_locals` - // (e.g. `for (let i = 0; i < arr.length; i++)` where `i` is - // captured by a closure or escapes) skip the Let-site - // allocation; providing one here enables both `icmp slt i32` - // in the condition and `add i32 1` in Update. - let fresh = if !ctx.i32_counter_slots.contains_key(&counter_id) { - if let Some(counter_slot) = ctx.locals.get(&counter_id).cloned() { - let i32_slot = ctx.func.alloca_entry(I32); - let cur_dbl = ctx.block().load(DOUBLE, &counter_slot); - let cur_i32 = ctx.block().fptosi(DOUBLE, &cur_dbl, I32); - ctx.block().store(I32, &cur_i32, &i32_slot); - ctx.i32_counter_slots.insert(counter_id, i32_slot); - true - } else { - false - } + let i32_local_bound_slot: Option = if let Some((counter_id, bound_id, _op)) = + local_bound_classification + { + // Allocate a parallel i32 slot for the counter if not already + // present. Counters that fall outside `integer_locals` + // (e.g. `for (let i = 0; i < arr.length; i++)` where `i` is + // captured by a closure or escapes) skip the Let-site + // allocation; providing one here enables both `icmp slt i32` + // in the condition and `add i32 1` in Update. + let fresh = if !ctx.i32_counter_slots.contains_key(&counter_id) { + if let Some(counter_slot) = ctx.locals.get(&counter_id).cloned() { + let i32_slot = ctx.func.alloca_entry(I32); + let cur_dbl = ctx.block().load(DOUBLE, &counter_slot); + let cur_i32 = ctx.block().fptosi(DOUBLE, &cur_dbl, I32); + ctx.block().store(I32, &cur_i32, &i32_slot); + ctx.i32_counter_slots.insert(counter_id, i32_slot); + true } else { false - }; - local_bound_counter_i32_was_fresh = fresh; - // Hoist `fptosi(n)` to a fresh i32 alloca before the cond block - // so LLVM sees a loop-invariant integer bound — critical for - // SCEV / LoopVectorizer to recognize the induction variable. - // Repsel Phase 1: a canonical-i32 bound has no double slot — its - // i32 slot already holds the exact value, no conversion needed. - if let Some((bound_i32_slot, _rep)) = - crate::expr::canonical_local_i32_slot(ctx, bound_id) - { - let bound_i32 = ctx.block().load(I32, &bound_i32_slot); - let slot = ctx.func.alloca_entry(I32); - ctx.block().store(I32, &bound_i32, &slot); - Some(slot) - } else if let Some(bound_slot) = ctx.locals.get(&bound_id).cloned() { - let bound_dbl = ctx.block().load(DOUBLE, &bound_slot); - let bound_i32 = ctx.block().fptosi(DOUBLE, &bound_dbl, I32); - let slot = ctx.func.alloca_entry(I32); - ctx.block().store(I32, &bound_i32, &slot); - Some(slot) - } else { - None } } else { - local_bound_counter_i32_was_fresh = false; - None + false }; + local_bound_counter_i32_was_fresh = fresh; + // Hoist `fptosi(n)` to a fresh i32 alloca before the cond block + // so LLVM sees a loop-invariant integer bound — critical for + // SCEV / LoopVectorizer to recognize the induction variable. + // Repsel Phase 1: a canonical-i32 bound has no double slot — its + // i32 slot already holds the exact value, no conversion needed. + if let Some((bound_i32_slot, _rep)) = crate::expr::canonical_local_i32_slot(ctx, bound_id) { + let bound_i32 = ctx.block().load(I32, &bound_i32_slot); + let slot = ctx.func.alloca_entry(I32); + ctx.block().store(I32, &bound_i32, &slot); + Some(slot) + } else if let Some(bound_slot) = ctx.locals.get(&bound_id).cloned() { + let bound_dbl = ctx.block().load(DOUBLE, &bound_slot); + let bound_i32 = ctx.block().fptosi(DOUBLE, &bound_dbl, I32); + let slot = ctx.func.alloca_entry(I32); + ctx.block().store(I32, &bound_i32, &slot); + Some(slot) + } else { + None + } + } else { + local_bound_counter_i32_was_fresh = false; + None + }; // Issue #168 follow-up: when neither the `arr.length` hoist nor the static // `i < n` peephole fired, try the runtime-guarded path. We emit a // finite-integral-i32 guard and `fptosi(n)` once here, in the pre-loop From 6d1a517b68aadf33968978f9d980dbe3794cc580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 27 Jul 2026 13:00:54 +0200 Subject: [PATCH 3/3] docs: changelog fragment for #6903 Claude-Session: https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv --- changelog.d/6903-repsel-p1-canonical-i32-locals.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/6903-repsel-p1-canonical-i32-locals.md diff --git a/changelog.d/6903-repsel-p1-canonical-i32-locals.md b/changelog.d/6903-repsel-p1-canonical-i32-locals.md new file mode 100644 index 0000000000..e72d79f967 --- /dev/null +++ b/changelog.d/6903-repsel-p1-canonical-i32-locals.md @@ -0,0 +1,7 @@ +**perf(codegen): representation-selection Phase 1 — canonical unboxed i32 locals** (RFC `docs/representation-selection-rfc.md`, #6901) + +For a local whose representation is proven `I32`/`U32` (the existing `needs_i32_slot` proof — `integer_locals` incl. #6898's int-valued-TA locals, `unsigned_i32_locals` — plus an int-valued-TA canonical-only term), the i32 slot is now the canonical and only storage: no NaN-boxed double slot, no dual writes, no shadow-stack GC binding. A boxed double is materialized (`sitofp`/`uitofp`) only at genuinely-boxed use sites, and boxed values enter the slot through the NaN-safe `toint32_wrap` conversion (the OOB-`undefined` trap stays closed). + +Mechanism: `SlotRep { Boxed, I32, U32 }` + `FnCtx.local_slot_reps` (`expr/slot_rep.rs`) — the seed of the RFC's representation lattice; `i32_counter_slots` stays the single slot registry. All local access routes through rep-aware helpers: `LocalGet`/`LocalSet`/`Update`, `WithSet`, SIMD channel writeback, class-capture writeback, and the loop matcher gates / counter-slot lifecycle in `loops.rs`. Excluded (stay boxed): closure-referenced locals, params, async/generator bodies, module init, boxed/TDZ locals. + +Post-`opt -O3`, a looped integer mixer's carried value goes from `phi double` with per-iteration `fptosi`×3/`sitofp`×6 to `phi i32` with zero conversions in the loop. Gated by `PERRY_CANONICAL_I32_LOCALS` (default on; `0`/`off`/`false` restores the parallel-shadow model), keyed into the object cache. `PERRY_REPSEL_DEBUG=1` prints each canonical local at compile time. New gap test `test_gap_repsel_canonical_i32.ts` covers boxed-consumer materialization, loop-counter shapes, unsigned `>>> 0` above 2^31, const int-TA views, possibly-OOB TA seeds, `var` redeclaration, update-expression values, and the closure-capture exclusion — byte-exact under both flag arms and `PERRY_GC_FORCE_EVACUATE=1`.