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
1 change: 1 addition & 0 deletions changelog.d/9084-packed-loop-read-admissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Packed-loop read admissions: if-condition reads admit the stable clone; the plain packed clones gain reduce-accumulator proofs and guarded-read numeric facts (`s += a[i]` is a bare fadd, `if (a[i] < 0)` a bare fcmp inside fast clones); call-free read bodies take the store arm's relaxed hazard eligibility so locally-built arrays version — count loops 4.6 → 1.6 ns/element.
11 changes: 10 additions & 1 deletion crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,15 @@ pub(crate) struct PackedF64LoopFact {
/// RHS is numeric bits (side-exiting otherwise) and skip the per-iteration
/// store guard — the range guard already proved bounds and mutability.
pub allow_holes: bool,
/// Plain locals the packed fast preheader proved to hold a Number (one
/// tag test per admitted accumulator) whose every in-body write is
/// numeric-preserving — the packed twin of
/// `StablePackedLoopFact::numeric_accumulators`. `is_numeric_expr`
/// consults this for `LocalGet`, which is what lets `s += arr[i]` inside
/// the fast clone lower to a native `fadd` instead of
/// `js_dynamic_string_or_number_add` on every iteration. Scope-safe by
/// construction: the fact is pushed around the fast-clone lowering only.
pub numeric_accumulators: Vec<u32>,
/// True when a *range* guard (hole-tolerant or dense) validated the whole
/// constant-offset index window `[start + min_offset, bound + max_offset)`
/// at loop entry — `arr[i ± c]` loads may use non-zero offsets even
Expand Down Expand Up @@ -2611,7 +2620,7 @@ mod fs_await;
mod index_get;
#[cfg(test)]
mod index_get_claim_tests;
mod masked_window;
pub(crate) mod masked_window;
#[cfg(test)]
mod null_default_numeric_add_tests;

Expand Down
144 changes: 135 additions & 9 deletions crates/perry-codegen/src/stmt/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,83 @@ fn lower_numeric_range_add_loop(
Ok(true)
}

/// Collect the body's numeric reduce accumulators for a packed fast clone
/// and emit one Number tag test each in the current (fast preheader) block,
/// branching to the slow preheader when any holds a non-Number — the
/// induction base case, exactly like the stable-packed clone's admission.
/// The returned ids ride the scope's `PackedF64LoopFact`, where
/// `is_numeric_expr` consults them so `s += arr[i]` lowers to a native
/// `fadd` instead of `js_dynamic_string_or_number_add` per iteration.
/// Range-loop wrapper: accumulators are collected against the loop's single
/// counter-accessed array (the `arr[counter]` leaf of the accumulator walk).
/// Multiple counter-accessed arrays decline — no admitted leaf would span
/// them all.
fn emit_range_loop_accumulator_admission(
ctx: &mut FnCtx<'_>,
matched: &PackedF64RangeLoop,
body: &[Stmt],
slow_pre_label: &str,
block_prefix: &str,
) -> Vec<u32> {
let mut counter_arrays = matched
.arrays
.iter()
.filter(|access| access.counter.is_some())
.map(|access| access.array_id);
let Some(array_id) = counter_arrays.next() else {
return Vec::new();
};
if counter_arrays.next().is_some() {
return Vec::new();
}
emit_packed_numeric_accumulator_admission(
ctx,
body,
array_id,
matched.counter_id,
slow_pre_label,
block_prefix,
)
}

fn emit_packed_numeric_accumulator_admission(
ctx: &mut FnCtx<'_>,
body: &[Stmt],
array_id: u32,
counter_id: u32,
slow_pre_label: &str,
block_prefix: &str,
) -> Vec<u32> {
let accumulators = super::stable_packed_accumulator::collect_numeric_accumulators(
ctx, body, array_id, counter_id,
);
if accumulators.is_empty() {
return accumulators;
}
let mut all_numbers: Option<String> = None;
for id in &accumulators {
let Some(slot) = ctx.locals.get(id).cloned() else {
return Vec::new();
};
let value = ctx.block().load(DOUBLE, &slot);
let is_number = emit_js_value_is_number(ctx, &value);
all_numbers = Some(match all_numbers {
Some(prev) => ctx.block().and(I1, &prev, &is_number),
None => is_number,
});
}
let all_numbers = all_numbers.expect("at least one accumulator");
let acc_ok_idx = ctx.new_block(&format!("{block_prefix}.acc.ok"));
let acc_ok_label = ctx.block_label(acc_ok_idx);
// A non-Number accumulator (a string total, a BigInt) takes the slow
// clone before the first fast iteration; nothing has run yet, so the
// slow clone sees pristine state.
ctx.block()
.cond_br(&all_numbers, &acc_ok_label, slow_pre_label);
ctx.current_block = acc_ok_idx;
accumulators
}

fn lower_packed_f64_versioned_for(
ctx: &mut FnCtx<'_>,
init: Option<&Stmt>,
Expand Down Expand Up @@ -585,6 +662,14 @@ fn lower_packed_f64_versioned_for(
let packed_scope_id = ctx.next_loop_proof_scope_id();

ctx.current_block = fast_pre_idx;
let numeric_accumulators = emit_packed_numeric_accumulator_admission(
ctx,
body,
matched.array_id,
matched.counter_id,
&slow_pre_label,
loop_label,
);
ctx.packed_f64_loop_facts.push(PackedF64LoopFact {
index_local_id: matched.counter_id,
array_local_id: matched.array_id,
Expand All @@ -594,6 +679,7 @@ fn lower_packed_f64_versioned_for(
array_kind: matched.array_kind,
allow_holes: false,
window_validated: false,
numeric_accumulators,
});
lower_for_after_init(
ctx,
Expand Down Expand Up @@ -1575,6 +1661,7 @@ fn push_packed_f64_range_facts(
slow_pre_label: &str,
values_i32: bool,
allow_masked_stores: bool,
numeric_accumulators: &[u32],
) {
for access in &matched.arrays {
if access.counter.is_some() {
Expand All @@ -1590,6 +1677,7 @@ fn push_packed_f64_range_facts(
// hole-tolerant.
allow_holes: !matched.dense,
window_validated: true,
numeric_accumulators: numeric_accumulators.to_vec(),
});
}
if let Some((lo, hi)) = access.stat {
Expand Down Expand Up @@ -1997,6 +2085,13 @@ fn lower_packed_f64_range_versioned_for(

ctx.current_block = fast_i32_pre_idx;
let scope_i32 = ctx.next_loop_proof_scope_id();
let range_numeric_accumulators = emit_range_loop_accumulator_admission(
ctx,
&matched,
body,
&slow_pre_label,
"packed_f64_range.fast_i32",
);
push_packed_f64_range_facts(
ctx,
&matched,
Expand All @@ -2005,6 +2100,7 @@ fn lower_packed_f64_range_versioned_for(
&slow_pre_label,
true,
false,
&range_numeric_accumulators,
);
lower_for_after_init_with_i32_bound(
ctx,
Expand All @@ -2026,6 +2122,13 @@ fn lower_packed_f64_range_versioned_for(

ctx.current_block = fast_pre_idx;
let scope_f64 = ctx.next_loop_proof_scope_id();
let range_numeric_accumulators = emit_range_loop_accumulator_admission(
ctx,
&matched,
body,
&slow_pre_label,
"packed_f64_range.fast",
);
push_packed_f64_range_facts(
ctx,
&matched,
Expand All @@ -2034,6 +2137,7 @@ fn lower_packed_f64_range_versioned_for(
&slow_pre_label,
false,
has_stores,
&range_numeric_accumulators,
);
lower_for_after_init_with_i32_bound(
ctx,
Expand Down Expand Up @@ -2065,6 +2169,13 @@ fn lower_packed_f64_range_versioned_for(
let packed_scope_id = ctx.next_loop_proof_scope_id();

ctx.current_block = fast_pre_idx;
let range_numeric_accumulators = emit_range_loop_accumulator_admission(
ctx,
&matched,
body,
&slow_pre_label,
"packed_f64_range.classic",
);
push_packed_f64_range_facts(
ctx,
&matched,
Expand All @@ -2073,6 +2184,7 @@ fn lower_packed_f64_range_versioned_for(
&slow_pre_label,
false,
false,
&range_numeric_accumulators,
);
lower_for_after_init_with_i32_bound(
ctx,
Expand Down Expand Up @@ -4278,12 +4390,24 @@ fn match_packed_f64_versioned_loop(
}
let store_array_kind =
supported_packed_numeric_loop_store_kind(ctx, body, hoist.arr_id, hoist.counter_id);
// The relaxed classifier above exists only for the exact guarded store
// loop. Other loop bodies keep the ordinary materialization-hazard gate.
if ordinary_hoist.is_none() && store_array_kind.is_none() {
// A call-free READ body earns the same relaxation the store arm below
// documents: the entry guard revalidates the actual receiver/layout and
// the matched body cannot call out or invalidate it, so the conservative
// whole-function materialization hazard (tripped by the very
// `new Array(n).fill(0)` construction calls that build these buffers) is
// not load-bearing. A wrong static hint is one failed guard -> slow
// clone, never a wrong answer.
let read_body_is_safe = store_array_kind.is_none()
&& body
.iter()
.all(|stmt| stmt_is_packed_f64_loop_safe(ctx, stmt, hoist.arr_id, hoist.counter_id));
// The relaxed classifier above once served only the exact guarded store
// loop; call-free read bodies now qualify by the argument above. Every
// other body keeps the ordinary materialization-hazard gate.
if ordinary_hoist.is_none() && store_array_kind.is_none() && !read_body_is_safe {
return None;
}
let binding_is_eligible = if store_array_kind.is_some() {
let binding_is_eligible = if store_array_kind.is_some() || read_body_is_safe {
// A helper call that produced the binding marks it with the
// conservative whole-function materialization hazard. For this exact
// store-loop shape that history is irrelevant: the entry guard
Expand Down Expand Up @@ -4316,18 +4440,20 @@ fn match_packed_f64_versioned_loop(
&& local_is_u32_array(ctx, hoist.arr_id)
{
PackedNumericLoopKind::U32
} else if ctx.native_facts.proves_packed_f64_array(hoist.arr_id) {
} else if ctx.native_facts.proves_packed_f64_array(hoist.arr_id) || read_body_is_safe {
// Same relaxation as the binding gate above: for a call-free read
// body the F64 guard re-proves the packed layout at entry, so the
// whole-function provenance fact is a hint, not a requirement. (The
// declared number-array check below still applies; a mis-hinted
// non-packed array fails the guard into the slow clone.)
PackedNumericLoopKind::F64
} else {
return None;
};
if !local_is_number_array(ctx, hoist.arr_id) {
return None;
}
let body_is_supported = store_array_kind.is_some()
|| body
.iter()
.all(|stmt| stmt_is_packed_f64_loop_safe(ctx, stmt, hoist.arr_id, hoist.counter_id));
let body_is_supported = store_array_kind.is_some() || read_body_is_safe;
if !body_is_supported {
return None;
}
Expand Down
67 changes: 63 additions & 4 deletions crates/perry-codegen/src/stmt/stable_packed_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,17 @@ fn target_below_numeric_operator(
if matches!(expr, Expr::Closure { .. }) {
return false;
}
let child_numeric_context =
numeric_context || matches!(expr, Expr::Binary { .. } | Expr::NumberCoerce(_));
// Compare counts: a relational/equality test against the other operand
// consumes the element numerically for admission purposes. If the
// elements turn out non-numeric at runtime, the entry guard's
// `require_numeric` arm fails and the generic loop runs — a wrong hint
// here is a failed admission, never a wrong answer (same contract as
// `Binary`).
let child_numeric_context = numeric_context
|| matches!(
expr,
Expr::Binary { .. } | Expr::NumberCoerce(_) | Expr::Compare { .. }
);
let mut found = false;
perry_hir::walker::walk_expr_children(expr, &mut |child| {
if !found
Expand Down Expand Up @@ -136,6 +145,9 @@ fn leading_read_requires_numeric(body: &[Stmt], array_id: u32, counter_id: u32)
| Stmt::Expr(expr)
| Stmt::Throw(expr)
| Stmt::Return(Some(expr)) => expr,
// `if (arr[i] < 0) ...`: the leading read lives in the CONDITION.
// A read only in the branches keeps the conservative answer.
Stmt::If { condition, .. } => condition,
_ => return false,
};
target_below_numeric_operator(expr, array_id, counter_id, false)
Expand Down Expand Up @@ -188,6 +200,29 @@ fn stmt_flags(stmt: &Stmt, array_id: u32, counter_id: u32) -> (bool, bool) {
| Stmt::Return(Some(expr)) => {
expr_flags(expr, array_id, counter_id, &mut target, &mut call);
}
// A conditional's reads and calls count — in the CONDITION and in
// both branches. Invisible `If`s made `if (arr[i] < 0) count++`
// unmatchable as a leading read, and (worse) hid later reads inside
// branches from the replay-safety check below.
Stmt::If {
condition,
then_branch,
else_branch,
} => {
expr_flags(condition, array_id, counter_id, &mut target, &mut call);
for inner in then_branch {
let (t, c) = stmt_flags(inner, array_id, counter_id);
target |= t;
call |= c;
}
if let Some(else_branch) = else_branch {
for inner in else_branch {
let (t, c) = stmt_flags(inner, array_id, counter_id);
target |= t;
call |= c;
}
}
}
_ => {}
}
(target, call)
Expand Down Expand Up @@ -1327,10 +1362,34 @@ pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool {
let Expr::IndexGet { object, index } = expr else {
return false;
};
let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object.as_ref(), index.as_ref())
else {
let Expr::LocalGet(array_id) = object.as_ref() else {
return false;
};
// Masked-window reads inside a dense/range fast clone: the entry guard
// proved every in-window slot numeric (dense) or the load hole-checks and
// side-exits BEFORE producing a value (hole-tolerant range), so the value
// an enclosing expression consumes is always a genuine number. Without
// this, `s += a[i & K]` inside the clone lowered its `+` through
// `js_dynamic_string_or_number_add` on every iteration.
if crate::expr::masked_window::masked_window_fact_for_index(ctx, *array_id, index.as_ref())
.is_some()
{
return true;
}
let Expr::LocalGet(counter_id) = index.as_ref() else {
return false;
};
// Versioned / range packed-f64 clone facts carry the same proof for the
// exact `a[counter]` read: the packed load's hole/value check side-exits
// to the slow clone before the value is consumed. I32/U32 kinds
// materialize via sitofp/uitofp — numeric by construction.
if ctx
.packed_f64_loop_facts
.iter()
.any(|fact| fact.array_local_id == *array_id && fact.index_local_id == *counter_id)
{
return true;
}
ctx.stable_packed_loop_facts.iter().rev().any(|fact| {
fact.numeric_elements
&& fact.array_local_id == *array_id
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/type_analysis/numeric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool {
// the BigInt-aware `js_dynamic_mul`. This set proves the
// value is a Number from the WRITES, so reassignment is fine.
|| ctx.number_by_construction_locals.contains(id)
// The packed-f64 clone twin of the stable-packed arm above:
// tag-tested in the versioned/range fast preheader, and every
// in-clone write is numeric-preserving by the accumulator
// walk.
|| ctx
.packed_f64_loop_facts
.iter()
.rev()
.any(|fact| fact.numeric_accumulators.contains(id))
}
// NOTE: Expr::Compare is NOT numeric — it produces a NaN-boxed
// TAG_TRUE/TAG_FALSE which `fcmp one cond, 0.0` would handle
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/type_analysis/pod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,15 @@ pub(crate) fn expr_may_return_boxed_value_from_raw_f64_fallback(
.as_ref()
.is_some_and(crate::typed_shape::type_is_raw_f64_candidate),
Expr::IndexGet { object, .. } => {
// Inside a packed/stable fast clone the guarded read either
// produces a genuine raw double or side-exits to the slow clone
// BEFORE the value is consumed — there is no boxed fallback edge
// at all, so the hazard is off. This is what lets
// `if (a[i] < 0)` in a versioned clone keep the bare `fcmp`
// instead of `js_rel_lt` per iteration.
if crate::stmt::stable_packed_loop::has_numeric_index_fact(ctx, expr) {
return false;
}
receiver_class_name(ctx, object)
.as_deref()
.is_some_and(crate::type_analysis::is_numeric_typed_array_class)
Expand Down
Loading
Loading