diff --git a/changelog.d/11788-10511-bitwise.md b/changelog.d/11788-10511-bitwise.md new file mode 100644 index 0000000000..81f06e95b5 --- /dev/null +++ b/changelog.d/11788-10511-bitwise.md @@ -0,0 +1,30 @@ +perf: bitwise ops on destructured or annotated locals run native inside a Number-versioned loop clone (#10511) + +Two changes: + +1. Unary `~` on an operand the compiler cannot prove is a Number now takes the + same inline guard as the binary bitwise operators (`|v| < 2^63`, with the + exact `js_dynamic_bitnot` on the cold arm), instead of calling the helper + on every evaluation. BigInt, `valueOf` and throwing coercions keep their + exact semantics on the cold arm. + +2. New loop tier `stmt/number_local_loop.rs`. In a receiver-free loop whose + bitwise operators read locals with no function-scope Number proof (noble's + `let { A, B, C, D } = this` SHA-2 round), a local is Number at every read + inside the loop when (1) it holds a Number at entry, which one inline test + checks, (2) every write that can execute in the loop, in the body AND the + condition AND the update clause, produces a Number given the admitted + locals (a greatest fixed point), and (3) no closure, `await`, `yield`, + boxed cell or module global can write it. The loop then runs in a clone + whose 5L Number scope holds those locals, each in a plain F64 alloca that + is written back on exit. When the entry test fails, the ordinary loop runs. + +Result (instr/iter): `destructure` 212 to 57 and `annotated` 213 to 57 (node +41). `prop` (78) and `bigmask` (186) are dominated by `any`-receiver property +reads that are not hoisted, not by bitwise ops, and are unchanged. Tests: IR +tests in `stmt/number_local_loop_tests.rs` and +`expr/unary_bitnot_tests.rs`. Sabotaging the condition/update walk or the fixed +point turns the clause and concatenation witnesses red. The gap test +`test_gap_10511_number_local_loop.ts` covers ToInt32 edge values, non-Number +entries, clause writes, break/return/throw exits, nested loops, and BigInt +TypeErrors. diff --git a/changelog.d/11788-10718-array-rmw.md b/changelog.d/11788-10718-array-rmw.md new file mode 100644 index 0000000000..d52a3c9cfe --- /dev/null +++ b/changelog.d/11788-10718-array-rmw.md @@ -0,0 +1,28 @@ +perf: array read-modify-write in a multi-statement loop body runs on the dense range tier (#10718) + +`for (let i = 0; i < N; i++) { a[i] = a[i] + 1; s += a[i]; }` fell off every +loop tier. The classic range mode admits only one statement, because a side exit +after a store would replay the store. The dense mode, which has no side exits, +admitted only masked `a[e & K]` stores. The loop therefore ran on the generic +path and re-validated the receiver for each of its three accesses: 206 +instructions per element against node's 39. + +The dense mode now admits counter-offset stores (`a[i] = …`, `a[i ± c] = …`) +under the same rule as its masked stores. The entry guard validates the whole +counter window (in bounds, hole-free, raw-f64, plain, integrity-clean), and the +stored value must be a statically genuine double: a literal, the counter, a +guarded element read, or `+ - * /` and negation over those. So the store needs +no value check and no side exit, and an iteration still runs entirely in one +copy. The compound-assignment alias fold (#10743) now also runs over +multi-statement bodies, so `a[i] += 1; s += a[i]` takes the same path. The +matcher verifies accumulators with the same array set the lowering uses, which +lifts the old single-array restriction (`a[i] = a[i] + b[i]; s += a[i]`). + +Result: `arrRmw` goes from 206 to 14.6 instructions per element (node 39.2), and +the single-statement form goes from 24.5 to 20.5. Tests: IR tests in +`stmt/range_loop_dense_store_tests.rs`. Removing the genuine-RHS rule turns +`declines_a_store_whose_value_is_not_provably_a_double` red (verified by +sabotage). The gap test `test_gap_10718_array_rmw_dense.ts` covers holes, +deleted elements, out-of-bounds windows, numeric strings, BigInt, `valueOf`, +element-kind changes between loop entries, frozen arrays, aliasing, and +-0/NaN/Infinity. diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 6b72429f83..4fe0f5ee58 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -415,7 +415,7 @@ fn lower_guarded_numeric_arith( /// exponent/mantissa tower. The Numbers it turns away (NaN, ±Infinity, /// |v| >= 2^63) are the ones ToInt32 has to special-case, and the cold arm's /// helper already does. -fn emit_is_int64_exact_number(ctx: &mut FnCtx<'_>, value: &str) -> String { +pub(super) fn emit_is_int64_exact_number(ctx: &mut FnCtx<'_>, value: &str) -> String { const TWO_POW_63: &str = "0x43E0000000000000"; let magnitude = ctx .block() @@ -423,6 +423,13 @@ fn emit_is_int64_exact_number(ctx: &mut FnCtx<'_>, value: &str) -> String { ctx.block().fcmp("olt", &magnitude, TWO_POW_63) } +/// Whether an unproven bitwise operand takes the inline guard (#10418, +/// #10511): both A/B knobs must be on. Unary `~` shares the binary +/// operators' gate so one switch reverts every bitwise guard together. +pub(super) fn guarded_bitwise_enabled() -> bool { + guarded_arith_enabled() && inline_nonbigint_bitwise_enabled() +} + /// `PERRY_GUARDED_ARITH=0` restores the unconditional dynamic helper for /// `-`, `*`, `/` and the bitwise operators. fn guarded_arith_enabled() -> bool { diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 1ffce791e9..f041b006a7 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -44,12 +44,9 @@ mod foreign_counter; pub(super) mod guarded_array; pub(crate) use foreign_counter::{ affine_counter_occurrences, affine_index_fits_i64, emit_affine_index_i64_with, - packed_f64_loop_index_parts, -}; -use foreign_counter::{ - affine_packed_loop_read, emit_affine_index_i64, foreign_packed_loop_read, - packed_f64_loop_offset_read, + packed_f64_loop_index_parts, packed_f64_loop_offset_read, }; +use foreign_counter::{affine_packed_loop_read, emit_affine_index_i64, foreign_packed_loop_read}; pub(crate) use guarded_array::emit_array_region_guard; mod inline_dyn_typed_array; diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index e1ab347552..a15f75a567 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -219,7 +219,12 @@ fn packed_f64_loop_fact_for_index( ) -> Option<(PackedF64LoopFact, u32, i32)> { let (idx_id, offset) = super::packed_f64_loop_index_parts(index)?; let fact = packed_f64_loop_fact(ctx, arr_id, idx_id)?; - if offset != 0 && !fact.allow_holes { + // A dense range guard validated the whole constant-offset window too + // (`window_validated` without `allow_holes`), so its offsets are proven + // exactly like the hole-tolerant guard's (#10718). Only the length-bound + // guard of the versioned matcher leaves an offset store unproven; an + // affine fact proves the receiver only. + if offset != 0 && !fact.allow_holes && (!fact.window_validated || fact.affine_indices) { return None; } Some((fact, idx_id, offset)) @@ -1017,7 +1022,28 @@ pub(crate) fn lower( // than abort codegen, let U32 facts fall through to the // generic/bounded array-store path below (correct, just // not the packed fast path). - if !matches!(fact.array_kind, PackedNumericLoopKind::U32) { + // A dense range copy has no side exit to take: an + // iteration must run entirely in one copy, because a + // multi-statement body may already have stored. The + // matcher admits a store there only with a statically + // genuine RHS (`dense_masked_store_rhs_is_admissible`, + // this predicate's match-time twin); a store that + // reaches here without that proof is matcher/lowering + // drift and must not get a side-exiting store. + let dense_copy = + !fact.allow_holes && fact.window_validated && !fact.affine_indices; + let side_exit_forbidden_but_needed = dense_copy + && !super::masked_window::masked_store_rhs_is_genuine_f64( + ctx, + value.as_ref(), + ); + debug_assert!( + !side_exit_forbidden_but_needed, + "dense range store without a genuine-f64 RHS proof" + ); + if !matches!(fact.array_kind, PackedNumericLoopKind::U32) + && !side_exit_forbidden_but_needed + { if let Some(i32_slot) = ctx.i32_counter_slots.get(&idx_id).cloned() { let idx_i32 = load_packed_loop_index_i32(ctx, &i32_slot, offset); return lower_packed_numeric_loop_index_set( diff --git a/crates/perry-codegen/src/expr/masked_window.rs b/crates/perry-codegen/src/expr/masked_window.rs index cb01e3fea4..fe67209ba4 100644 --- a/crates/perry-codegen/src/expr/masked_window.rs +++ b/crates/perry-codegen/src/expr/masked_window.rs @@ -256,6 +256,7 @@ pub(crate) fn masked_store_rhs_is_genuine_f64(ctx: &FnCtx<'_>, expr: &Expr) -> b Expr::IndexGet { object, index } => match object.as_ref() { Expr::LocalGet(arr_id) => { masked_window_fact_for_index(ctx, *arr_id, index.as_ref()).is_some() + || counter_read_is_genuine_f64(ctx, *arr_id, index.as_ref()) } _ => false, }, @@ -277,6 +278,24 @@ pub(crate) fn masked_store_rhs_is_genuine_f64(ctx: &FnCtx<'_>, expr: &Expr) -> b } } +/// #10718: a counter-offset read (`a[i]`, `a[i ± c]`) served by an active +/// raw-f64 `PackedF64LoopFact` materializes a genuine double. Every lowering +/// of such a read either loads a slot the entry guard proved holds a raw-f64 +/// number (canonical by the store-side invariant) or leaves the iteration +/// first: the hole-tolerant range copy hole-checks and side-exits, and an +/// offset the length-bound guard does not cover bounds-checks and +/// side-exits — both BEFORE the value exists. Affine (receiver-only) facts +/// and the i32/u32 kinds are excluded; they are not this argument. +fn counter_read_is_genuine_f64(ctx: &FnCtx<'_>, arr_id: u32, index: &Expr) -> bool { + crate::expr::index_get::packed_f64_loop_offset_read(ctx, arr_id, index).is_some_and( + |(fact, idx_id, _, _)| { + matches!(fact.array_kind, crate::expr::PackedNumericLoopKind::F64) + && !fact.affine_indices + && ctx.i32_counter_slots.contains_key(&idx_id) + }, + ) +} + /// Emit the in-window element STORE for a store-admitting fact: the dense /// entry guard proved a plain, integrity-clean raw-f64 numeric array with the /// whole static window in bounds and hole-free, and the caller proved the diff --git a/crates/perry-codegen/src/expr/unary.rs b/crates/perry-codegen/src/expr/unary.rs index 91072e8ea4..72407642a4 100644 --- a/crates/perry-codegen/src/expr/unary.rs +++ b/crates/perry-codegen/src/expr/unary.rs @@ -30,6 +30,56 @@ use super::{is_known_i32_range, lower_expr, FnCtx}; /// `lower_bitwise_operand_i32` only produces a value for an operand it can /// prove is a Number, and a BigInt-typed operand declines up front, so a /// BigInt `~x` (a BigInt result) still reaches the dynamic helper in [`lower`]. +/// `~v` for an operand the compiler cannot prove is a Number (#10511): one +/// inline test, the numeric arm inline, the dynamic helper cold. +/// +/// This is the unary twin of the binary bitwise guard +/// (`binary::lower_guarded_numeric_arith`) and uses the same test, +/// `|v| < 2^63`. Every NaN-boxed tag is a NaN bit pattern, so a string, +/// object, boolean, `undefined`, an int32 box or a BigInt fails the ordered +/// compare and reaches `js_dynamic_bitnot`, which keeps ToNumeric's exact +/// semantics: a BigInt stays a BigInt (`~1n === -2n`), `valueOf` runs, and +/// a throwing coercion throws. The Numbers the test turns away — NaN, +/// ±Infinity, |v| >= 2^63 — are the ones whose ToInt32 needs the helper's +/// special cases; for every Number that passes, truncating to i64 and keeping +/// the low 32 bits IS ToInt32, so the numeric arm is one `fptosi`. +/// +/// `v` is the already-lowered operand: nothing runs between its evaluation +/// and either arm, so no rooting window opens that the old unconditional +/// helper call did not already have. +fn lower_guarded_bitnot(ctx: &mut FnCtx<'_>, v: &str) -> String { + let is_num = super::binary::emit_is_int64_exact_number(ctx, v); + let fast_idx = ctx.new_block("guarded_bitnot.numeric"); + let slow_idx = ctx.new_block("guarded_bitnot.dynamic"); + let merge_idx = ctx.new_block("guarded_bitnot.merge"); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&is_num, &fast_label, &slow_label); + + ctx.current_block = fast_idx; + let fast_val = { + let blk = ctx.block(); + let i = blk.toint32_fast(v); + let flipped = blk.xor(I32, &i, "-1"); + blk.sitofp(I32, &flipped, DOUBLE) + }; + let fast_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = slow_idx; + super::emit_versioned_loop_callback_deopt(ctx); + let slow_val = ctx + .block() + .call(DOUBLE, "js_dynamic_bitnot", &[(DOUBLE, v)]); + let slow_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(DOUBLE, &[(&fast_val, &fast_end), (&slow_val, &slow_end)]) +} + pub(crate) fn lower_bitnot_value( ctx: &mut FnCtx<'_>, operand: &Expr, @@ -140,6 +190,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let blk = ctx.block(); let flipped = blk.xor(I32, &i, "-1"); Ok(blk.sitofp(I32, &flipped, DOUBLE)) + } else if super::binary::guarded_bitwise_enabled() { + Ok(lower_guarded_bitnot(ctx, &v)) } else { Ok(blk.call(DOUBLE, "js_dynamic_bitnot", &[(DOUBLE, &v)])) } diff --git a/crates/perry-codegen/src/expr/unary_bitnot_tests.rs b/crates/perry-codegen/src/expr/unary_bitnot_tests.rs index 5d1608be12..3789335587 100644 --- a/crates/perry-codegen/src/expr/unary_bitnot_tests.rs +++ b/crates/perry-codegen/src/expr/unary_bitnot_tests.rs @@ -75,6 +75,26 @@ fn erased_direct_bitnot_retains_bigint_dispatch() { ); } +/// #10511: the erased operand keeps the helper, but only on the COLD arm of +/// one inline test (the binary bitwise guard, `|v| < 2^63`), so a Number +/// operand never reaches the call. Reverting `~` to the unconditional helper +/// keeps the call but loses the guard blocks, and this goes red. +#[test] +fn erased_direct_bitnot_takes_the_inline_number_guard() { + let ir = ir_for( + "guarded_erased_bitnot", + vec![erased(X, "x"), result(bitnot(Expr::LocalGet(X)))], + ); + assert!( + ir.contains("guarded_bitnot.numeric") && ir.contains("guarded_bitnot.dynamic"), + "an erased ~x must be one inline Number test with a cold helper arm:\n{ir}" + ); + assert!( + ir.contains("0x43E0000000000000"), + "the guard is the binary bitwise operators' |v| < 2^63 test:\n{ir}" + ); +} + #[test] fn potentially_bigint_binary_result_retains_bitnot_dispatch() { let unknown_value = |id| Expr::PropertyGet { diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 66eefdb5d9..02aefd9709 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -1798,8 +1798,33 @@ fn match_packed_f64_range_loop( &mut accesses, &mut pending_accumulators, ) { - return range_loop_reject("body_not_admissible"); + // #10718: `a[i] += x; s += a[i]` reaches here as alias `Let`s + // followed by the store, so the store's receiver is the alias, + // not a tracked array. Fold every compound-assign alias run into + // the statement it was minted for (the same fold the classic + // walk retries with, generalised to several statements) and + // retry the dense walk on the folded body. The folded body is + // what the guarded clones lower; the slow clone keeps the + // statements as written. + accesses.clear(); + pending_accumulators.clear(); + let folded = packed_f64_range_loop_compound_alias_fold_all(body).filter(|folded| { + packed_f64_range_loop_dense_body_collect( + ctx, + folded, + counter_id, + bound_local, + &mut accesses, + &mut pending_accumulators, + ) + }); + let Some(folded) = folded else { + return range_loop_reject("body_not_admissible"); + }; + range_loop_trace("dense_compound_assign_alias_fold"); + fast_body = Some(folded); } + let collected_body: &[Stmt] = fast_body.as_deref().unwrap_or(body); if !pending_accumulators.is_empty() { // The peel above assumed each pending local numeric; that holds // only if the lowering will actually admit it (entry tag check + @@ -1807,18 +1832,24 @@ fn match_packed_f64_range_loop( // the SAME array selection `emit_range_loop_accumulator_admission` // uses -- if the two disagree, the clone would contain a dynamic // `+` (a collecting call) under facts that forbid one. - if accesses.len() != 1 { - return range_loop_reject("accumulator_needs_single_array"); - } - let array_id = *accesses.keys().next().expect("len checked"); + // + // #10718: the verification passes exactly the arguments the + // lowering's `emit_range_loop_accumulator_admission` derives — + // the whole guarded array set and the same masked/affine flags — + // so the two cannot disagree. (It used to demand a single array, + // a leftover from before the accumulator walk took the set, which + // kept `a[i] = a[i] + b[i]; s += a[i]` off the tier.) + let array_ids: std::collections::BTreeSet = accesses.keys().copied().collect(); + let masked_reads_validated = accesses.values().any(|a| a.stat.is_some()); + let affine_reads = accesses.values().any(|a| a.affine); let admitted = super::stable_packed_accumulator::collect_numeric_accumulators( ctx, - body, - &std::collections::BTreeSet::from([array_id]), + collected_body, + &array_ids, counter_id, true, - true, - false, + masked_reads_validated, + affine_reads, ); if !pending_accumulators.iter().all(|id| admitted.contains(id)) { return range_loop_reject("accumulator_not_provable"); @@ -2081,6 +2112,89 @@ pub(super) fn packed_f64_range_loop_compound_alias_fold(body: &[Stmt]) -> Option /// for the read index, once for the store index — where the original /// evaluated it once. And its value cannot change between those two /// evaluations, because the admitted statement writes no local at all. +/// #10718: [`packed_f64_range_loop_compound_alias_fold`] over a whole +/// statement list, for the DENSE walk. Every run of compiler-minted +/// `__cmpd_*` alias `Let`s is folded into the one statement that follows it; +/// every other statement is kept as written. `None` when nothing was folded, +/// when a run cannot be folded, or when an alias is still mentioned anywhere +/// in the result. +/// +/// The single-statement fold's argument carries over per run: the aliases +/// are bound immediately before their statement, nothing runs between the +/// binding and the statement, and the statement itself is a whitelisted +/// walk that writes no local. The last check makes the "read only by the +/// statement they were minted for" property structural rather than assumed: +/// a later statement that read an alias would read a slot the fast clone +/// never writes, so it rejects the fold instead. +pub(super) fn packed_f64_range_loop_compound_alias_fold_all(body: &[Stmt]) -> Option> { + fn is_alias_let(stmt: &Stmt) -> Option { + match stmt { + Stmt::Let { + id, + name, + mutable: false, + init: Some(_), + .. + } if name.starts_with("__cmpd_") => Some(*id), + _ => None, + } + } + fn stmts_touch_local(stmts: &[Stmt], id: u32) -> bool { + stmts.iter().any(|stmt| match stmt { + Stmt::Let { init: None, .. } => false, + Stmt::Let { + id: bound, + init: Some(init), + .. + } => *bound == id || packed_f64_range_loop_expr_touches_local(init, id), + Stmt::Expr(expr) => packed_f64_range_loop_expr_touches_local(expr, id), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + packed_f64_range_loop_expr_touches_local(condition, id) + || stmts_touch_local(then_branch, id) + || else_branch + .as_deref() + .is_some_and(|branch| stmts_touch_local(branch, id)) + } + // Any other statement is outside the dense grammar; answer + // conservatively so the fold is refused rather than trusted. + _ => true, + }) + } + let mut out = Vec::with_capacity(body.len()); + let mut aliases = Vec::new(); + let mut index = 0; + while index < body.len() { + if is_alias_let(&body[index]).is_none() { + out.push(body[index].clone()); + index += 1; + continue; + } + let start = index; + while index < body.len() { + match is_alias_let(&body[index]) { + Some(id) => aliases.push(id), + None => break, + } + index += 1; + } + if index == body.len() { + return None; + } + out.extend(packed_f64_range_loop_compound_alias_fold( + &body[start..=index], + )?); + index += 1; + } + if aliases.is_empty() || aliases.iter().any(|id| stmts_touch_local(&out, *id)) { + return None; + } + Some(out) +} + fn packed_f64_range_loop_alias_init_is_stable(init: &perry_hir::Expr) -> bool { use perry_hir::{BinaryOp, Expr}; match init { @@ -2559,6 +2673,31 @@ fn packed_f64_range_loop_dense_stmts_collect( let perry_hir::Expr::LocalGet(arr_id) = object else { return false; }; + // #10718: a COUNTER-offset store (`a[i] = a[i] + 1`, + // `a[i + 1] = …`) takes the same rule as a masked one. + // The dense guard validates the counter window + // `[start + min, bound + max)` exactly as it validates a + // static window — in bounds, hole-free, raw-f64, plain, + // integrity-clean — and the RHS must be a statically + // genuine double, so the store has no value check and no + // side exit. That is what makes a store legal in a + // multi-statement dense body: an iteration still runs + // entirely in one copy, so a store that already ran can + // never be replayed by the slow copy. + if let Some(offset) = packed_f64_range_loop_index_offset(index, counter_id) { + if !masked_window_expression_is_non_collecting(ctx, value) + || !dense_masked_store_rhs_is_admissible( + ctx, value, counter_id, accesses, + ) + || !packed_f64_range_loop_pure_expr_collect( + value, counter_id, true, accesses, None, + ) + { + return false; + } + record_packed_f64_range_access(accesses, *arr_id, offset, true); + continue; + } if !masked_window_expression_is_non_collecting(ctx, index) || !masked_window_expression_is_non_collecting(ctx, value) || !dense_masked_store_rhs_is_admissible(ctx, value, counter_id, accesses) @@ -2672,10 +2811,15 @@ fn dense_masked_store_rhs_is_admissible( match expr { Expr::Number(_) | Expr::Integer(_) => true, Expr::LocalGet(id) => *id == counter_id || ctx.i32_counter_slots.contains_key(id), + // A counter-offset read (`a[i]`, `a[i ± c]`) is admitted too: the + // pure walk records its window for the same dense guard, and the + // lowering serves it from the clone's `PackedF64LoopFact` as a raw + // load of a slot that guard proved holds a raw-f64 number (#10718). Expr::IndexGet { object, index } => { matches!(object.as_ref(), Expr::LocalGet(_)) - && crate::collectors::static_index_window(index) - .is_some_and(|(lo, hi)| lo >= 0 && hi < i64::from(i32::MAX)) + && (packed_f64_range_loop_index_offset(index, counter_id).is_some() + || crate::collectors::static_index_window(index) + .is_some_and(|(lo, hi)| lo >= 0 && hi < i64::from(i32::MAX))) } // Float arithmetic over admitted operands — see the lowering-side // twin (`masked_store_rhs_is_genuine_f64`) for the argument. `%`/`**` @@ -6758,6 +6902,14 @@ pub(crate) fn lower_for( return Ok(()); } + // #10511: a receiver-free loop whose bitwise operators read locals the + // function scope cannot prove Number runs in a clone versioned on one + // entry test per local (the 5L rule over the loop's own writes). It + // touches no receiver, so the region tier below has nothing to plan. + if super::number_local_loop::lower(ctx, init, condition, update, body)? { + return Ok(()); + } + // Step 4b (#10884): every specialised tier above declined; a loop (or // body) region guards its receivers once here, in the preheader, and // splits the body when the tier below lowers it (`stmt::region_loop`). diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 7fa5aa52f4..0262d4b66f 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -40,12 +40,17 @@ mod let_stmt_facts; mod let_stmt_var_redeclare_tests; mod loops; mod masked_window_region; +mod number_local_loop; +#[cfg(test)] +mod number_local_loop_tests; #[cfg(test)] mod packed_range_global_cache_rooting_tests; #[cfg(test)] mod prealloc_module_global_tests; #[cfg(test)] mod prealloc_tdz_path_tests; +#[cfg(test)] +mod range_loop_dense_store_tests; pub(crate) mod region_loop; mod region_read_stmts; pub(crate) mod stable_packed_accumulator; diff --git a/crates/perry-codegen/src/stmt/number_local_loop.rs b/crates/perry-codegen/src/stmt/number_local_loop.rs new file mode 100644 index 0000000000..6ecf5d50c0 --- /dev/null +++ b/crates/perry-codegen/src/stmt/number_local_loop.rs @@ -0,0 +1,397 @@ +//! #10511: a loop whose bitwise operators read locals the compiler cannot +//! prove are Numbers, versioned on ONE entry test per local. +//! +//! The shape is noble's SHA-2 round and every hash like it: +//! +//! ```ts +//! let { A, B, C, D } = this; // or `let A: number = st.A` +//! for (let i = 0; i < n; i++) { +//! const t = (rotr(A, 7) ^ ((B & C) ^ (~B & D))) + i | 0; +//! D = C; C = B; B = A; A = t; +//! } +//! ``` +//! +//! Nothing proves `A..D` are Numbers at the function scope: their first write +//! is a property read, and a declared `number` is a hint, not a proof. So +//! every bitwise operand pays the inline guard (`|v| < 2^63`, a cold helper +//! arm), every copy pays the boxed-write protocol, and the values round-trip +//! through doubles between operators. +//! +//! The rule (charter step 5L — Number is a dataflow fact, not a per-site +//! admission): a local is Number at every read inside the loop when +//! +//! 1. it holds a Number when the loop is entered — tested once, here; and +//! 2. every write to it that can execute inside the loop — in the body, the +//! update clause AND the condition, at any nesting depth — produces a +//! Number whenever the candidates read by that write are Numbers +//! (a greatest fixed point, so `D = C; C = B; B = A; A = t` proves all +//! four together); and +//! 3. nothing else can write it: it is not captured by any closure, not a +//! boxed / mapped-`arguments` cell, not a module global, and no closure, +//! `await` or `yield` appears in the loop. +//! +//! (1) is the induction base, (2) the step, (3) closes the set of writes. The +//! condition and update clauses are walked exactly like the body: a write in +//! `for (…; …; i++, s = "a")` withdraws `s`. When the entry test passes, the +//! loop runs in a clone whose 5L Number scope +//! (`ReceiverDescriptorTable::materialize_number_locals`) holds the admitted +//! locals; `local_is_number` answers from it, so their bitwise operators lower +//! natively and their writes carry no pointer protocol; each lives in a plain +//! F64 alloca for the clone's duration and is written back to its slot on +//! every exit that can observe it. When the test fails, the ordinary loop +//! runs. A Number's representation is its raw double, so neither clone ever +//! puts a non-Number bit pattern where the other expects a JS value. +//! +//! Scope of the tier: loops that touch no receiver (no property or element +//! access) — those belong to the region tier that follows — and that use an +//! admitted local as a direct bitwise operand, the only shape where the clone +//! pays for its code size. + +use std::collections::{BTreeSet, HashSet}; + +use anyhow::Result; +use perry_hir::{BinaryOp, Expr, Stmt, UnaryOp}; + +use super::loops::{emit_js_value_is_number, lower_for_after_init}; +use crate::expr::{lower_expr, FnCtx}; +use crate::types::I1; + +/// Expression nodes beyond which the clone's code size is not worth it. +const MAX_LOOP_NODES: usize = 4000; + +/// `PERRY_NUMBER_LOCAL_LOOP=0` keeps every loop on the ordinary lowering. +fn enabled() -> bool { + !matches!( + std::env::var("PERRY_NUMBER_LOCAL_LOOP").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) +} + +fn trace(what: &str) { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + if *ON.get_or_init(|| std::env::var("PERRY_PACKED_LOOP_TRACE").as_deref() == Ok("1")) { + eprintln!("[number-local-loop] {what}"); + } +} + +/// Everything the matcher learned about one loop's statements and clauses. +#[derive(Default)] +struct LoopFacts<'a> { + /// Every write that can execute inside the loop, by local: + /// `Some(rhs)` for an assignment, `None` for `++`/`--`. + writes: std::collections::BTreeMap>>, + /// Locals declared inside the loop (including the `for` init's): their + /// value at entry is not the value the clone reads, so they are never + /// entry-tested candidates. + declared: HashSet, + /// Locals that appear directly as an operand of a bitwise operator. + bitwise_operands: HashSet, + nodes: usize, +} + +impl<'a> LoopFacts<'a> { + /// `false` when the loop contains a construct this tier does not reason + /// about; the caller then declines the whole loop. + fn walk_stmts(&mut self, stmts: &'a [Stmt]) -> bool { + stmts.iter().all(|stmt| self.walk_stmt(stmt)) + } + + fn walk_stmt(&mut self, stmt: &'a Stmt) -> bool { + match stmt { + Stmt::Let { id, init, .. } => { + self.declared.insert(*id); + init.as_ref().is_none_or(|init| self.walk_expr(init)) + } + Stmt::Expr(expr) | Stmt::Throw(expr) => self.walk_expr(expr), + Stmt::Return(value) => value.as_ref().is_none_or(|value| self.walk_expr(value)), + Stmt::Break | Stmt::Continue => true, + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.walk_expr(condition) + && self.walk_stmts(then_branch) + && else_branch + .as_deref() + .is_none_or(|branch| self.walk_stmts(branch)) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + self.walk_expr(condition) && self.walk_stmts(body) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_none_or(|init| self.walk_stmt(init)) + && condition.as_ref().is_none_or(|c| self.walk_expr(c)) + && update.as_ref().is_none_or(|u| self.walk_expr(u)) + && self.walk_stmts(body) + } + // try/switch/labels and the box-management statements carry + // control flow or storage protocols this tier does not model. + _ => false, + } + } + + fn walk_expr(&mut self, expr: &'a Expr) -> bool { + self.nodes += 1; + if self.nodes > MAX_LOOP_NODES { + return false; + } + match expr { + // A closure could capture, an `await`/`yield` suspends with the + // frame's storage protocol, and a receiver access belongs to the + // region tier that runs after this one. + Expr::Closure { .. } + | Expr::Await(_) + | Expr::Yield { .. } + | Expr::PropertyGet { .. } + | Expr::PropertySet { .. } + | Expr::IndexGet { .. } + | Expr::IndexSet { .. } + | Expr::PutValueSet { .. } => return false, + Expr::LocalSet(id, value) => { + self.writes.entry(*id).or_default().push(Some(value)); + } + Expr::Update { id, .. } => { + self.writes.entry(*id).or_default().push(None); + } + Expr::Binary { op, left, right } if is_bitwise(*op) => { + for operand in [left.as_ref(), right.as_ref()] { + if let Expr::LocalGet(id) = operand { + self.bitwise_operands.insert(*id); + } + } + } + Expr::Unary { + op: UnaryOp::BitNot, + operand, + } => { + if let Expr::LocalGet(id) = operand.as_ref() { + self.bitwise_operands.insert(*id); + } + } + _ => {} + } + let mut ok = true; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + ok = ok && self.walk_expr(child); + }); + ok + } +} + +fn is_bitwise(op: BinaryOp) -> bool { + matches!( + op, + BinaryOp::BitAnd + | BinaryOp::BitOr + | BinaryOp::BitXor + | BinaryOp::Shl + | BinaryOp::Shr + | BinaryOp::UShr + ) +} + +/// Does evaluating `expr` produce a Number (or throw) whenever every local in +/// `numbers` holds a Number? The rule's step, per operator: +/// +/// * `+` needs both operands Number (otherwise it may concatenate); +/// * the other arithmetic and bitwise operators need ONE: a Number operand +/// paired with a BigInt throws, so the result is a Number or nothing; +/// * `>>>`, unary `+` and `Number(x)` produce a Number or throw for any input; +/// * `-x`, `~x` and `x++` keep a BigInt a BigInt, so they need `x` Number. +/// +/// Anything else defers to the function-scope proof (`is_numeric_expr`). +fn produces_number(ctx: &FnCtx<'_>, expr: &Expr, numbers: &BTreeSet) -> bool { + match expr { + Expr::Integer(_) | Expr::Number(_) => true, + Expr::LocalGet(id) => { + numbers.contains(id) || crate::type_analysis::is_numeric_expr(ctx, expr) + } + Expr::LocalSet(_, value) => produces_number(ctx, value, numbers), + Expr::Update { id, .. } => { + numbers.contains(id) || crate::type_analysis::is_numeric_expr(ctx, &Expr::LocalGet(*id)) + } + Expr::Binary { op, left, right } => match op { + BinaryOp::Add => { + produces_number(ctx, left, numbers) && produces_number(ctx, right, numbers) + } + BinaryOp::UShr => true, + _ => produces_number(ctx, left, numbers) || produces_number(ctx, right, numbers), + }, + Expr::Unary { op, operand } => match op { + UnaryOp::Pos => true, + UnaryOp::Neg | UnaryOp::BitNot => produces_number(ctx, operand, numbers), + UnaryOp::Not => false, + }, + Expr::NumberCoerce(_) => true, + Expr::Conditional { + then_expr, + else_expr, + .. + } => produces_number(ctx, then_expr, numbers) && produces_number(ctx, else_expr, numbers), + _ => crate::type_analysis::is_numeric_expr(ctx, expr), + } +} + +/// The admitted locals, or `None` when the loop is not this tier's. +fn match_number_locals( + ctx: &FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&Expr>, + update: Option<&Expr>, + body: &[Stmt], +) -> Option> { + if !ctx.pending_labels.is_empty() || ctx.try_depth != 0 || ctx.is_async_fn { + return None; + } + let mut facts = LoopFacts::default(); + // The init runs once, before the entry test: its declarations are loop + // locals, and its writes are covered by the test itself. + if let Some(Stmt::Let { id, .. }) = init { + facts.declared.insert(*id); + } + let walked = condition.is_none_or(|c| facts.walk_expr(c)) + && update.is_none_or(|u| facts.walk_expr(u)) + && facts.walk_stmts(body); + if !walked { + return None; + } + let mut numbers: BTreeSet = facts + .writes + .keys() + .copied() + .filter(|id| { + !facts.declared.contains(id) + && ctx.locals.contains_key(id) + && !ctx.boxed_vars.contains(id) + && !ctx.closure_captures.contains_key(id) + && !ctx.repsel_closure_ref_locals.contains(id) + && !ctx.module_globals.contains_key(id) + && !ctx.i32_counter_slots.contains_key(id) + && !ctx.local_slot_reps.contains_key(id) + && !crate::type_analysis::local_is_number(ctx, *id) + && !ctx.numeric_accumulator_f64_slots.contains_key(id) + }) + .collect(); + loop { + let rejected: Vec = numbers + .iter() + .copied() + .filter(|id| { + !facts.writes[id].iter().all(|write| match write { + Some(rhs) => produces_number(ctx, rhs, &numbers), + // `x++` on a Number is a Number. + None => true, + }) + }) + .collect(); + if rejected.is_empty() { + break; + } + for id in rejected { + numbers.remove(&id); + } + } + if !numbers.iter().any(|id| facts.bitwise_operands.contains(id)) { + return None; + } + Some(numbers.into_iter().collect()) +} + +/// Try the tier. `init` has already been lowered by the caller. +pub(super) fn lower( + ctx: &mut FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&Expr>, + update: Option<&Expr>, + body: &[Stmt], +) -> Result { + if !enabled() { + return Ok(false); + } + let Some(numbers) = match_number_locals(ctx, init, condition, update, body) else { + return Ok(false); + }; + trace(&format!("admitted {} local(s)", numbers.len())); + + // The induction base: one Number test per admitted local, read through + // the ordinary `LocalGet` lowering so every storage protocol is honoured. + let mut all_numbers: Option = None; + let mut entry_values: Vec<(u32, String)> = Vec::with_capacity(numbers.len()); + for id in &numbers { + let value = lower_expr(ctx, &Expr::LocalGet(*id))?; + entry_values.push((*id, value.clone())); + 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("the matcher admits at least one local"); + + let fast_idx = ctx.new_block("for.number_locals.fast.preheader"); + let slow_idx = ctx.new_block("for.number_locals.slow.preheader"); + let merge_idx = ctx.new_block("for.number_locals.merge"); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&all_numbers, &fast_label, &slow_label); + + ctx.current_block = fast_idx; + let scope_id = ctx.next_loop_proof_scope_id(); + ctx.receiver_descriptors + .materialize_number_locals(scope_id, &numbers); + // Inside the clone each admitted local lives in a plain F64 alloca — the + // packed tiers' unboxed-accumulator redirect (`LocalGet`/`LocalSet` read + // and write `numeric_accumulator_f64_slots`), so LLVM keeps the value in + // a register instead of reloading it through the root slot's protocol + // at every use. A Number carries no heap edge, so the alloca needs no + // root. The real slot keeps the entry value (a Number) for the clone's + // duration and is brought up to date on every way out that can observe + // it: the loop's fall-through/`break` exit below, and every `throw` + // site (`flush_packed_accumulator_locals`). A `return` leaves the + // function, and no closure can read these locals (admission rule 3). + let mut redirected: Vec<(u32, String, String)> = Vec::with_capacity(entry_values.len()); + for (id, value) in &entry_values { + let Some(real_slot) = ctx.locals.get(id).cloned() else { + continue; + }; + let alloca = ctx.func.alloca_entry(crate::types::DOUBLE); + ctx.block().store(crate::types::DOUBLE, value, &alloca); + ctx.numeric_accumulator_f64_slots + .insert(*id, alloca.clone()); + redirected.push((*id, alloca, real_slot)); + } + let fast = lower_for_after_init(ctx, init, condition, update, body, "for.number_locals_fast"); + if fast.is_ok() && !ctx.block().is_terminated() { + for (_, alloca, real_slot) in &redirected { + // Same argument as the packed tiers' `finish`: a Number's bits + // are its NaN-box and carry no heap edge, so no barrier. + let value = ctx.block().load(crate::types::DOUBLE, alloca); + ctx.block().store(crate::types::DOUBLE, &value, real_slot); + } + } + for (id, _, _) in &redirected { + ctx.numeric_accumulator_f64_slots.remove(id); + } + ctx.receiver_descriptors.dematerialize_scope(scope_id); + fast?; + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = slow_idx; + lower_for_after_init(ctx, init, condition, update, body, "for.number_locals_slow")?; + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = merge_idx; + Ok(true) +} diff --git a/crates/perry-codegen/src/stmt/number_local_loop_tests.rs b/crates/perry-codegen/src/stmt/number_local_loop_tests.rs new file mode 100644 index 0000000000..bd7ee6feb8 --- /dev/null +++ b/crates/perry-codegen/src/stmt/number_local_loop_tests.rs @@ -0,0 +1,229 @@ +//! #10511: the number-local loop clone, read from the emitted IR. +//! +//! The fixture is the SHA-2 shape reduced to one local: +//! +//! ```ts +//! function f(seed: any, n: number) { +//! let h: any = seed; +//! for (let i = 0; i < n; i++) { h = ~h ^ i; } +//! return h; +//! } +//! ``` +//! +//! `h` has no function-scope Number proof (its first write is an `any` +//! parameter), so before the tier every `~h` paid the bitwise guard and its +//! cold `js_dynamic_bitnot` arm. Each `declines_*` test is the witness for one +//! admission rule: it goes red when that rule is deleted from the matcher. +//! In particular the two clause tests are the C1 lesson (a proof that walks +//! only the body misses `for (…; …; s = "a")`): sabotage the condition or +//! update walk in `LoopFacts` and they fail. + +#![cfg(test)] + +use perry_hir::types::Type; +use perry_hir::{ + BinaryOp, CompareOp, Expr, Function, LogicalOp, Module, Param, Stmt, UnaryOp, UpdateOp, +}; + +use crate::{compile_module, CompileOptions}; + +const SEED: u32 = 1; +const N: u32 = 2; +const H: u32 = 3; +const I: u32 = 4; + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn bin(op: BinaryOp, left: Expr, right: Expr) -> Expr { + Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} + +/// `h = ~h ^ i` +fn mix_write() -> Stmt { + Stmt::Expr(Expr::LocalSet( + H, + Box::new(bin( + BinaryOp::BitXor, + Expr::Unary { + op: UnaryOp::BitNot, + operand: Box::new(Expr::LocalGet(H)), + }, + Expr::LocalGet(I), + )), + )) +} + +fn counter_let() -> Stmt { + Stmt::Let { + id: I, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + } +} + +fn i_lt_n() -> Expr { + Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(I)), + right: Box::new(Expr::LocalGet(N)), + } +} + +fn i_inc() -> Expr { + Expr::Update { + id: I, + op: UpdateOp::Increment, + prefix: false, + } +} + +fn module_ir(condition: Expr, update: Expr, body: Vec) -> String { + let mut module = Module::new("number_local_loop"); + module.init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::Integer(1), Expr::Integer(8)], + type_args: Vec::new(), + byte_offset: 0, + })); + module.functions.push(Function { + id: 1, + name: "f".to_string(), + type_params: Vec::new(), + params: vec![param(SEED, "seed", Type::Any), param(N, "n", Type::Number)], + return_type: Type::Any, + body: vec![ + Stmt::Let { + id: H, + name: "h".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::LocalGet(SEED)), + }, + Stmt::For { + init: Some(Box::new(counter_let())), + condition: Some(condition), + update: Some(update), + body, + }, + Stmt::Return(Some(Expr::LocalGet(H))), + ], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: true, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + String::from_utf8(compile_module(&module, opts).expect("module compiles")) + .expect("LLVM IR is UTF-8") +} + +/// The text of the blocks between the first label starting with `from` and +/// the first later label starting with `to`. +fn blocks_between<'a>(ir: &'a str, from: &str, to: &str) -> &'a str { + let start = ir + .find(&format!("\n{from}")) + .unwrap_or_else(|| panic!("premise: no block `{from}*` in\n{ir}")); + let end = ir[start + 1..] + .find(&format!("\n{to}")) + .map_or(ir.len(), |offset| start + 1 + offset); + &ir[start..end] +} + +#[test] +fn admits_a_bitwise_loop_over_an_unproven_local() { + let ir = module_ir(i_lt_n(), i_inc(), vec![mix_write()]); + assert!( + ir.contains("for.number_locals.fast.preheader"), + "the loop must take the number-local clone\n{ir}" + ); + // The fast clone: `~h` and `^` are native, no guard and no helper arm. + let fast = blocks_between(&ir, "for.number_locals_fast", "for.number_locals_slow"); + assert!( + !fast.contains("@js_dynamic_bitnot") && !fast.contains("@js_dynamic_bitxor"), + "#10511: the fast clone must not call the dynamic bitwise helpers\n{fast}" + ); + assert!( + !fast.contains("guarded_bitnot.dynamic"), + "#10511: a proven-Number `~h` needs no guard diamond\n{fast}" + ); + // Premise: the slow clone is the ordinary lowering, which still guards + // `~h` with the cold helper (so the assertions above are about the clone, + // not about a lowering that never calls the helper at all). + let slow = blocks_between(&ir, "for.number_locals_slow", "for.number_locals.merge"); + assert!( + slow.contains("@js_dynamic_bitnot"), + "premise: the slow clone keeps the guarded helper arm\n{slow}" + ); +} + +#[test] +fn declines_when_the_update_clause_writes_a_non_number() { + // for (let i = 0; i < n; h = "a") { h = ~h ^ i; i++; } + let update = Expr::LocalSet(H, Box::new(Expr::String("a".to_string()))); + let body = vec![mix_write(), Stmt::Expr(i_inc())]; + let ir = module_ir(i_lt_n(), update, body); + assert!( + !ir.contains("for.number_locals.fast.preheader"), + "a non-Number write in the UPDATE clause must withdraw `h`\n{ir}" + ); +} + +#[test] +fn declines_when_the_condition_writes_a_non_number() { + // for (let i = 0; (h = "x") && i < n; i++) { h = ~h ^ i; } + let condition = Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::LocalSet(H, Box::new(Expr::String("x".to_string())))), + right: Box::new(i_lt_n()), + }; + let ir = module_ir(condition, i_inc(), vec![mix_write()]); + assert!( + !ir.contains("for.number_locals.fast.preheader"), + "a non-Number write in the CONDITION must withdraw `h`\n{ir}" + ); +} + +#[test] +fn declines_a_body_write_that_may_concatenate() { + // h = h + "" can produce a string. + let body = vec![ + mix_write(), + Stmt::Expr(Expr::LocalSet( + H, + Box::new(bin( + BinaryOp::Add, + Expr::LocalGet(H), + Expr::String(String::new()), + )), + )), + ]; + let ir = module_ir(i_lt_n(), i_inc(), body); + assert!( + !ir.contains("for.number_locals.fast.preheader"), + "`h + \"\"` is not Number-producing, so `h` must not be admitted\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/stmt/range_loop_dense_store_tests.rs b/crates/perry-codegen/src/stmt/range_loop_dense_store_tests.rs new file mode 100644 index 0000000000..bac2655cde --- /dev/null +++ b/crates/perry-codegen/src/stmt/range_loop_dense_store_tests.rs @@ -0,0 +1,282 @@ +//! #10718: counter-offset stores in the DENSE range-loop mode, read from the +//! emitted IR. +//! +//! The fixture is the issue's read-modify-write row: +//! +//! ```ts +//! function f(a: number[], x: any) { +//! let s = 0; +//! for (let i = 0; i < 400; i++) { a[i] = a[i] + 1; s = s + a[i]; } +//! return s; +//! } +//! ``` +//! +//! Two statements, so the classic (single-statement) mode cannot take it, and +//! the dense mode admitted only masked stores — the loop paid ~206 +//! instructions per element on the generic path. Dense mode's guarantee is +//! that an iteration runs entirely in one copy, which is what makes a store +//! legal in a multi-statement body; the price is that the store may have NO +//! side exit, so its value must be a statically genuine double. +//! `declines_a_store_whose_value_is_not_provably_a_double` is the witness for +//! that rule: delete the RHS check from the dense counter-store arm and it +//! goes red (verified by sabotage) — the store would need a value check whose +//! failure has nowhere safe to go. + +#![cfg(test)] + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module, Param, Stmt, UpdateOp}; + +use super::loops::packed_f64_range_loop_compound_alias_fold_all; +use crate::{compile_module, CompileOptions}; + +const A: u32 = 1; +const X: u32 = 2; +const S: u32 = 3; +const I: u32 = 4; +const BASE: u32 = 5; +const KEY: u32 = 6; + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn bin(op: BinaryOp, left: Expr, right: Expr) -> Expr { + Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + } +} + +fn get(arr: u32, idx: u32) -> Expr { + Expr::IndexGet { + object: Box::new(Expr::LocalGet(arr)), + index: Box::new(Expr::LocalGet(idx)), + } +} + +fn set(arr: u32, idx: u32, value: Expr) -> Stmt { + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(arr)), + index: Box::new(Expr::LocalGet(idx)), + value: Box::new(value), + }) +} + +/// `s = s + a[i]` +fn accumulate() -> Stmt { + Stmt::Expr(Expr::LocalSet( + S, + Box::new(bin(BinaryOp::Add, Expr::LocalGet(S), get(A, I))), + )) +} + +fn alias(id: u32, name: &str, init: Expr) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Number, + mutable: false, + init: Some(init), + } +} + +fn module_ir(body: Vec) -> String { + let mut module = Module::new("dense_rmw"); + module.init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::Array(vec![Expr::Number(0.5)]), Expr::Integer(1)], + type_args: Vec::new(), + byte_offset: 0, + })); + module.functions.push(Function { + id: 1, + name: "f".to_string(), + type_params: Vec::new(), + params: vec![ + param(A, "a", Type::Array(Box::new(Type::Number))), + param(X, "x", Type::Any), + ], + return_type: Type::Number, + body: vec![ + Stmt::Let { + id: S, + name: "s".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + }, + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: I, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(I)), + right: Box::new(Expr::Integer(400)), + }), + update: Some(Expr::Update { + id: I, + op: UpdateOp::Increment, + prefix: false, + }), + body, + }, + Stmt::Return(Some(Expr::LocalGet(S))), + ], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: true, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + String::from_utf8(compile_module(&module, opts).expect("module compiles")) + .expect("LLVM IR is UTF-8") +} + +const DENSE_GUARD: &str = "@js_typed_feedback_packed_f64_range_loop_guard_dense("; + +/// Whether the IR CALLS `callee`. A bare `contains` would also match the +/// module's `declare` line, which every module carries — a check that cannot +/// fail. +fn calls(ir: &str, callee: &str) -> bool { + ir.lines() + .any(|line| line.contains(" call ") && line.contains(callee)) +} + +#[test] +fn a_read_modify_write_body_takes_the_dense_tier() { + // a[i] = a[i] + 1; s = s + a[i]; + let body = vec![ + set(A, I, bin(BinaryOp::Add, get(A, I), Expr::Integer(1))), + accumulate(), + ]; + let ir = module_ir(body); + assert!( + calls(&ir, DENSE_GUARD), + "#10718: the two-statement RMW body must take the dense range tier\n{ir}" + ); + assert!( + !ir.contains("packed_f64_range_store.side_exit"), + "#10718: a dense store has no side exit — its value is proven a double\n{ir}" + ); +} + +#[test] +fn the_compound_spelling_folds_and_takes_the_dense_tier() { + // a[i] += 1; s = s + a[i]; (HIR: two alias lets, the store, the sum) + let body = vec![ + alias(BASE, "__cmpd_base_5", Expr::LocalGet(A)), + alias(KEY, "__cmpd_key_6", Expr::LocalGet(I)), + set( + BASE, + KEY, + bin(BinaryOp::Add, get(BASE, KEY), Expr::Integer(1)), + ), + accumulate(), + ]; + let ir = module_ir(body); + assert!( + calls(&ir, DENSE_GUARD), + "#10718: `a[i] += 1; s += a[i]` must fold and take the dense tier\n{ir}" + ); +} + +#[test] +fn declines_a_store_whose_value_is_not_provably_a_double() { + // a[i] = undefined; s = s + a[i]; + // `undefined` passes the non-collecting walk (evaluating it runs no + // code), so the ONLY gate between it and a raw `store double` of its + // NaN-box tag into a raw-f64 slot is the statically-genuine RHS rule. + let body = vec![set(A, I, Expr::Undefined), accumulate()]; + let ir = module_ir(body); + assert!( + !calls(&ir, DENSE_GUARD), + "#10718: a dense store needs a statically genuine double RHS\n{ir}" + ); +} + +#[test] +fn declines_a_store_that_may_concatenate() { + // a[i] = a[i] + x; s = s + a[i]; with `x: any`. + let body = vec![ + set(A, I, bin(BinaryOp::Add, get(A, I), Expr::LocalGet(X))), + accumulate(), + ]; + let ir = module_ir(body); + assert!( + !calls(&ir, DENSE_GUARD), + "#10718: `a[i] + x` may concatenate; it is no dense store value\n{ir}" + ); +} + +#[test] +fn fold_all_declines_an_alias_read_by_a_later_statement() { + // The alias must be read only by the statement it was minted for: a later + // statement that read it would read a slot the fast clone never writes. + let body = vec![ + alias(BASE, "__cmpd_base_5", Expr::LocalGet(A)), + alias(KEY, "__cmpd_key_6", Expr::LocalGet(I)), + set( + BASE, + KEY, + bin(BinaryOp::Add, get(BASE, KEY), Expr::Integer(1)), + ), + Stmt::Expr(Expr::LocalSet( + S, + Box::new(bin(BinaryOp::Add, Expr::LocalGet(S), get(BASE, KEY))), + )), + ]; + assert!( + packed_f64_range_loop_compound_alias_fold_all(&body).is_none(), + "an alias read after its statement must refuse the fold" + ); +} + +#[test] +fn fold_all_folds_every_alias_run() { + let run = |base: u32, key: u32| { + vec![ + alias(base, &format!("__cmpd_base_{base}"), Expr::LocalGet(A)), + alias(key, &format!("__cmpd_key_{key}"), Expr::LocalGet(I)), + set( + base, + key, + bin(BinaryOp::Add, get(base, key), Expr::Integer(1)), + ), + ] + }; + let mut body = run(BASE, KEY); + body.push(accumulate()); + body.extend(run(7, 8)); + let folded = packed_f64_range_loop_compound_alias_fold_all(&body).expect("both runs must fold"); + assert_eq!(folded.len(), 3, "two folded stores and the sum: {folded:?}"); + let text = format!("{folded:?}"); + for id in [BASE, KEY, 7, 8] { + assert!( + !text.contains(&format!("LocalGet({id})")), + "alias {id} survived the fold: {text}" + ); + } +} diff --git a/test-files/test_gap_10511_number_local_loop.ts b/test-files/test_gap_10511_number_local_loop.ts new file mode 100644 index 0000000000..d368067bbf --- /dev/null +++ b/test-files/test_gap_10511_number_local_loop.ts @@ -0,0 +1,170 @@ +// #10511: a loop whose bitwise operators read locals with no function-scope +// Number proof runs in a clone versioned on one entry test per local. Every +// case below must match the ordinary lowering: the values ToInt32 treats +// specially, entry values that are not Numbers (the slow clone), writes in +// the condition and update clauses that withdraw a local from the proof, +// early exits (break / return / throw) that must leave each local's real +// value behind, and BigInt operands that must keep throwing TypeError. + +class St { + A: number = 0x6a09e667 | 0; + B: number = 0xbb67ae85 | 0; + C: number = 0x3c6ef372 | 0; + D: number = 0xa54ff53a | 0; +} + +function rotr(w: number, s: number): number { + return (w << (32 - s)) | (w >>> s); +} + +// noble SHA2_32B.process. +function rounds(st: any, n: number): number { + let { A, B, C, D } = st; + for (let i = 0; i < n; i++) { + const t = (rotr(A, 7) ^ ((B & C) ^ (~B & D))) + i | 0; + D = C; C = B; B = A; A = t; + } + st.A = A; st.B = B; st.C = C; st.D = D; + return A; +} + +// Every special ToInt32 input, entering as the loop's local. +function mix(seed: any, n: number): string { + let h = seed; + let out = ""; + for (let i = 0; i < n; i++) { + h = (h ^ (h << 5)) + ~h; + out += String(h) + ","; + } + return out + String(h); +} + +// The local is only ever read, never written, before the first write: the +// entry value flows through unchanged when the loop runs zero times. +function zeroTrip(seed: any): any { + let h = seed; + for (let i = 0; i < 0; i++) h = ~h & 7; + return h; +} + +// A write in the UPDATE clause that is not a Number withdraws the local. +function updateWrite(n: number): string { + let s: any = 1; + let acc = 0; + for (let i = 0; i < n; i++, s = "a") { + acc = acc + (~s | 0); + } + return acc + "|" + s; +} + +// A write in the CONDITION clause that is not a Number withdraws it too. +function conditionWrite(n: number): string { + let s: any = 3; + let acc = 0; + let i = 0; + for (; (s = i < 2 ? s : "x"), i < n; i++) { + acc = acc ^ ~s; + } + return acc + "|" + s; +} + +// Early exits: the caller observes the local after a break and a return. +function breakOut(seed: number, n: number): number { + let h: number = seed; + for (let i = 0; i < n; i++) { + h = (h * 31 + i) & 0xffff; + if ((h & 3) === 3) break; + h = ~h ^ 0x55; + } + return h; +} +function returnOut(seed: number, n: number): number { + let h: number = seed; + for (let i = 0; i < n; i++) { + h = (h << 1) ^ i; + if (h > 1000) return h + 0.5; + } + return -h; +} + +// A throw from inside the loop, caught by the caller. +function throwOut(seed: number, n: number): number { + let h: number = seed; + for (let i = 0; i < n; i++) { + h = ~h ^ (i << 3); + if (i === 5) throw new Error("h=" + h); + } + return h; +} + +// A throw caught in the SAME function after the loop: the catch must see the +// value the loop wrote, not the entry value. +function throwCaught(seed: number): number { + let h: number = seed; + try { + for (let i = 0; i < 10; i++) { + h = (h ^ 0x3c) + 1 | 0; + if (i === 3) throw new Error("x"); + } + } catch (e) { + return h; + } + return -1; +} + +// Nested loops: the inner loop writes the outer loop's local. +function nested(seed: number): number { + let h: number = seed; + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 3; j++) { + h = (h >>> 1) ^ (~h & j); + } + h = h ^ i; + } + return h; +} + +const show = (label: string, v: unknown) => console.log(label, String(v)); + +const st = new St(); +show("rounds", rounds(st, 64)); +show("roundsState", [st.A, st.B, st.C, st.D].join(",")); +show("roundsZero", rounds({ A: 1.5, B: -0, C: NaN, D: "7" }, 0)); + +for (const seed of [0, -0, 1, -1, 1.5, -1.5, NaN, Infinity, -Infinity, 2 ** 31, 2 ** 32 + 5, -(2 ** 53), 1e300, "12", " 0x10 ", "abc", "", true, null, undefined, [3], { valueOf: () => 9 }]) { + show("mix " + (typeof seed === "string" ? JSON.stringify(seed) : Object.is(seed, -0) ? "-0" : String(seed)), mix(seed, 3)); +} +show("zeroTrip -0", Object.is(zeroTrip(-0), -0)); +show("zeroTrip str", zeroTrip("s")); +show("updateWrite", updateWrite(3)); +show("conditionWrite", conditionWrite(4)); +show("breakOut", breakOut(7, 100)); +show("returnOut", returnOut(3, 100)); +try { + throwOut(11, 10); +} catch (e) { + show("throwOut", (e as Error).message); +} +show("throwCaught", throwCaught(5)); +show("nested", nested(0x12345)); + +// BigInt operands: the entry test fails and the ordinary loop throws exactly +// where node does. +try { + show("bigint", mix(5n, 2)); +} catch (e) { + show("bigint", (e as Error).constructor.name); +} +try { + rounds({ A: 1n, B: 2n, C: 3n, D: 4n }, 2); + show("bigintRounds", "no throw"); +} catch (e) { + show("bigintRounds", (e as Error).constructor.name); +} +// BigInt-only arithmetic stays BigInt in the ordinary clone. +function bigOnly(seed: bigint, n: number): bigint { + let h = seed; + for (let i = 0; i < n; i++) h = (h ^ (h << 3n)) & 0xffffn; + return h; +} +show("bigOnly", bigOnly(7n, 5)); diff --git a/test-files/test_gap_10718_array_rmw_dense.ts b/test-files/test_gap_10718_array_rmw_dense.ts new file mode 100644 index 0000000000..76a88a83f6 --- /dev/null +++ b/test-files/test_gap_10718_array_rmw_dense.ts @@ -0,0 +1,129 @@ +// #10718: array read-modify-write in a multi-statement loop body +// (`a[i] = a[i] + 1; s += a[i]`, `a[i] += x; s += a[i]`). The dense range +// tier runs these with one entry guard and raw f64 loads/stores; every case +// below must match the generic path exactly, including the ones whose guard +// fails (holes, out-of-bounds windows, non-number elements, frozen arrays) +// and arrays whose element kind changes between loop entries. + +function rmw(a: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { a[i] = a[i] + 1; s += a[i]; } + return s; +} +function rmwCompound(a: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { a[i] += 0.5; s += a[i]; } + return s; +} +function rmwOffset(a: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { a[i + 1] = a[i] * 2 - a[i + 1]; s += a[i + 1]; } + return s; +} +function rmwTwoArrays(a: number[], b: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { a[i] = a[i] + b[i]; b[i] = a[i] - b[i]; s += a[i] * b[i]; } + return s; +} +function rmwNeg(a: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { a[i] = -a[i]; s += 1 / a[i]; } + return s; +} +function rmwDiv(a: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { a[i] = a[i] / 0; s += a[i]; } + return s; +} +function rmwAny(a: any, n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { a[i] = a[i] + 1; s += a[i]; } + return s; +} +function rmwBranch(a: number[], n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { + a[i] = a[i] * 3; + if (a[i] > 10) s += a[i]; else s -= 1; + } + return s; +} + +const show = (label: string, v: unknown, a?: unknown[]) => + console.log(label, String(v), a === undefined ? "" : a.map((x) => (Object.is(x, -0) ? "-0" : String(x))).join(",")); + +// Plain packed doubles: the fast copy. +let a: number[] = []; +for (let i = 0; i < 8; i++) a.push(i * 0.25); +show("rmw", rmw(a, 8), a); +show("rmwCompound", rmwCompound(a, 8), a); +show("rmwOffset", rmwOffset(a, 7), a); +let b: number[] = []; +for (let i = 0; i < 8; i++) b.push(8 - i); +show("rmwTwoArrays", rmwTwoArrays(a, b, 8), b); +// Aliasing: the same array through two bindings. +show("rmwAlias", rmwTwoArrays(b, b, 8), b); + +// -0, NaN, Infinity through the raw stores. +const z = [0, -0, 1, -1, NaN, Infinity, -Infinity, 2.5]; +show("rmwNeg", rmwNeg(z, 8), z); +const d = [1, -1, 0, -0, NaN, 3, -3, 0.5]; +show("rmwDiv", rmwDiv(d, 8), d); + +// Holes: the hole-free guard declines, the generic path runs. +const holes: number[] = new Array(6); +holes[0] = 1; holes[2] = 3; holes[5] = 6; +show("holes", rmw(holes, 6), holes); +const sparse = [1, 2, 3, 4]; +delete sparse[1]; +show("deleted", rmw(sparse, 4), sparse); + +// Out of bounds: the window exceeds the length, so the guard declines and +// the generic path grows the array and reads undefined. +const short = [1, 2, 3]; +show("oob", rmw(short, 5), short); +show("oobOffset", rmwOffset([1, 2], 3), []); + +// Non-number elements: strings that look numeric concatenate, BigInt throws. +const mixed: any[] = [1, "2", 3, "x"]; +show("strings", rmwAny(mixed, 4), mixed); +try { + rmwAny([1, 2n, 3], 3); + console.log("bigint no throw"); +} catch (e) { + console.log("bigint", (e as Error).constructor.name); +} +const objs: any[] = [1, { valueOf() { return 10; } }, 3]; +show("valueOf", rmwAny(objs, 3), objs.map((x) => typeof x)); + +// Element kind changes between loop entries: doubles, then a string is +// stored, then doubles again. +const k: any[] = [0.5, 1.5, 2.5, 3.5]; +const k1 = rmwAny(k, 4); +k[2] = "s"; +const k2 = rmwAny(k, 4); +k[2] = 7; +const k3 = rmwAny(k, 4); +show("kindChange", [k1, k2, k3].join("|"), k); + +// Integer-valued literal arrays and frozen arrays. +const ints = [1, 2, 3, 4, 5]; +show("ints", rmw(ints, 5), ints); +const frozen = Object.freeze([1, 2, 3]) as number[]; +try { + show("frozen", rmw(frozen, 3), frozen as number[]); +} catch (e) { + show("frozen", (e as Error).constructor.name, frozen as number[]); +} + +// A branch after the store. +const br = [1, 2, 3, 4, 5, 6]; +show("branch", rmwBranch(br, 6), br); + +// Zero-trip and large loops. +show("zero", rmw([1, 2, 3], 0), []); +const big: number[] = []; +for (let i = 0; i < 1000; i++) big.push(i % 7); +let total = 0; +for (let r = 0; r < 50; r++) total += rmw(big, 1000); +show("big", total, big.slice(0, 5));