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
7 changes: 7 additions & 0 deletions changelog.d/6903-repsel-p1-canonical-i32-locals.md
Original file line number Diff line number Diff line change
@@ -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`.
17 changes: 17 additions & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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(),
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
17 changes: 17 additions & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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(),
Expand Down
28 changes: 28 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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(),
Expand Down
28 changes: 28 additions & 0 deletions crates/perry-codegen/src/collectors/hir_facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
/// 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<u32>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -129,6 +137,10 @@ impl TypeFacts {
&self.representation.unsigned_i32_locals
}

pub(crate) fn int_valued_ta_locals(&self) -> &HashSet<u32> {
&self.representation.int_valued_ta_locals
}

pub(crate) fn not_bigint_locals(&self) -> &HashSet<u32> {
&self.representation.not_bigint_locals
}
Expand Down Expand Up @@ -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<u32> = 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);
}
}
}
Expand All @@ -345,11 +361,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(
Expand Down Expand Up @@ -396,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,
Expand Down
31 changes: 28 additions & 3 deletions crates/perry-codegen/src/collectors/i32_locals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub fn is_strictly_i32_bounded_expr(
flat_const_ids: &HashSet<u32>,
flat_row_alias_ids: &HashSet<u32>,
clamp_fn_ids: &HashSet<u32>,
int_ta_views: &HashMap<u32, i64>,
on_dep: &mut dyn FnMut(u32),
) -> bool {
use perry_hir::{BinaryOp, Expr};
Expand Down Expand Up @@ -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,
Expand All @@ -140,6 +153,13 @@ pub struct StrictWriteFacts {
pub saw_any: HashSet<u32>,
pub disqualified: HashSet<u32>,
copy_deps: HashMap<u32, Vec<u32>>,
/// 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<u32, i64>,
}

/// Judge one write to `id` against the oracle and fold the verdict into `out`.
Expand All @@ -152,16 +172,17 @@ fn record_strict_write(
clamp_fn_ids: &HashSet<u32>,
out: &mut StrictWriteFacts,
) {
out.saw_any.insert(id);
let mut deps: Vec<u32> = Vec::new();
let strict = is_strictly_i32_bounded_expr(
value,
known_i32_ranged,
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;
Expand Down Expand Up @@ -220,14 +241,18 @@ pub fn collect_strictly_i32_bounded_locals(
integer_locals: &HashSet<u32>,
flat_const_ids: &HashSet<u32>,
clamp_fn_ids: &HashSet<u32>,
int_ta_views: HashMap<u32, i64>,
) -> HashSet<u32> {
let mut flat_row_alias_ids: HashSet<u32> = HashSet::new();
collect_flat_row_aliases(stmts, flat_const_ids, &mut flat_row_alias_ids);

// 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,
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/collectors/integer_locals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32, i64> {
pub(crate) fn collect_const_int_ta_views(stmts: &[perry_hir::Stmt]) -> HashMap<u32, i64> {
use perry_hir::{Expr, Stmt};
let mut views: HashMap<u32, i64> = HashMap::new();
fn seed_stmt(stmt: &Stmt, views: &mut HashMap<u32, i64>) {
Expand Down Expand Up @@ -330,7 +330,7 @@ fn scan_ta_view_escapes_expr(e: &perry_hir::Expr, views: &mut HashMap<u32, i64>)

/// `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<u32, i64>, e: &perry_hir::Expr) -> bool {
pub(crate) fn is_proven_int_ta_load(views: &HashMap<u32, i64>, e: &perry_hir::Expr) -> bool {
use perry_hir::Expr;
let Expr::IndexGet { object, index } = e else {
return false;
Expand Down
17 changes: 10 additions & 7 deletions crates/perry-codegen/src/expr/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,26 +449,29 @@ 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!(
"{} = extractelement <4 x i32> {}, i32 {}",
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(())
}
5 changes: 4 additions & 1 deletion crates/perry-codegen/src/expr/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading