From c2464671262664d33447f42d915dc078970d8064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 11:13:01 +0200 Subject: [PATCH 1/3] perf: array read-modify-write in multi-statement loops takes the dense range tier (#10718) `a[i] = a[i] + 1; s += a[i]` fell off every loop tier. The classic range mode takes one statement, because a side exit after a store would replay the store, and the dense mode, which has no side exits, admitted only masked stores. The loop ran on the generic path at 206 instructions per element (node 39). The dense mode now admits counter-offset stores under the masked-store rule. 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, so the store has no value check and no side exit. The read and the write share that one guard, and the add is an unboxed fadd. The #10743 compound-assignment alias fold now runs over multi-statement bodies, and accumulator verification uses the lowering's own array set. arrRmw: 206 -> 14.6 instr/elem. Adds IR tests, including a sabotage-verified witness for the genuine-RHS rule, and test_gap_10718_array_rmw_dense.ts. --- changelog.d/PENDING-10718-array-rmw.md | 28 ++ crates/perry-codegen/src/expr/index_get.rs | 7 +- crates/perry-codegen/src/expr/index_set.rs | 30 +- .../perry-codegen/src/expr/masked_window.rs | 19 ++ crates/perry-codegen/src/stmt/loops.rs | 166 ++++++++++- crates/perry-codegen/src/stmt/mod.rs | 2 + .../src/stmt/range_loop_dense_store_tests.rs | 282 ++++++++++++++++++ test-files/test_gap_10718_array_rmw_dense.ts | 129 ++++++++ 8 files changed, 645 insertions(+), 18 deletions(-) create mode 100644 changelog.d/PENDING-10718-array-rmw.md create mode 100644 crates/perry-codegen/src/stmt/range_loop_dense_store_tests.rs create mode 100644 test-files/test_gap_10718_array_rmw_dense.ts diff --git a/changelog.d/PENDING-10718-array-rmw.md b/changelog.d/PENDING-10718-array-rmw.md new file mode 100644 index 0000000000..d52a3c9cfe --- /dev/null +++ b/changelog.d/PENDING-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/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/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 66eefdb5d9..958a39aef9 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. `%`/`**` diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 7fa5aa52f4..68a3defe22 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -46,6 +46,8 @@ mod packed_range_global_cache_rooting_tests; 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/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_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)); From 7e76bc0bc539dd33887be82e4223588211a83b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 11:13:01 +0200 Subject: [PATCH 2/3] perf: bitwise ops on unproven locals run native in a Number-versioned loop clone (#10511) Unary `~` on an operand with no Number proof now takes the binary bitwise operators' inline guard (`|v| < 2^63`, with js_dynamic_bitnot on the cold arm) instead of calling the helper on every evaluation. New tier stmt/number_local_loop.rs, for the noble SHA-2 shape `let { A, B, C, D } = this; for (...) { ... ~B ... D = C; ... }`. In a receiver-free loop, a local is Number at every read when: - it holds a Number at entry (one inline test); - every write that can execute in the loop, in the body, the condition and the update clause, produces a Number given the admitted locals (a greatest fixed point); and - 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. destructure 212 -> 57 and annotated 213 -> 57 instr/iter (node 41). Adds IR tests; the clause-walk and fixed-point witnesses are sabotage-verified. Adds test_gap_10511_number_local_loop.ts. --- changelog.d/PENDING-10511-bitwise.md | 30 ++ crates/perry-codegen/src/expr/binary.rs | 9 +- crates/perry-codegen/src/expr/unary.rs | 52 +++ .../src/expr/unary_bitnot_tests.rs | 20 + crates/perry-codegen/src/stmt/loops.rs | 8 + crates/perry-codegen/src/stmt/mod.rs | 3 + .../src/stmt/number_local_loop.rs | 397 ++++++++++++++++++ .../src/stmt/number_local_loop_tests.rs | 229 ++++++++++ .../test_gap_10511_number_local_loop.ts | 170 ++++++++ 9 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 changelog.d/PENDING-10511-bitwise.md create mode 100644 crates/perry-codegen/src/stmt/number_local_loop.rs create mode 100644 crates/perry-codegen/src/stmt/number_local_loop_tests.rs create mode 100644 test-files/test_gap_10511_number_local_loop.ts diff --git a/changelog.d/PENDING-10511-bitwise.md b/changelog.d/PENDING-10511-bitwise.md new file mode 100644 index 0000000000..81f06e95b5 --- /dev/null +++ b/changelog.d/PENDING-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/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/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 958a39aef9..02aefd9709 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -6902,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 68a3defe22..0262d4b66f 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -40,6 +40,9 @@ 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)] 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/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)); From 5201e16ae3b530ed02ce6d0dc0e307a05e384c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 3 Oct 2026 11:13:23 +0200 Subject: [PATCH 3/3] changelog: key the RMW and bitwise fragments to PR 11788 --- changelog.d/{PENDING-10511-bitwise.md => 11788-10511-bitwise.md} | 0 .../{PENDING-10718-array-rmw.md => 11788-10718-array-rmw.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{PENDING-10511-bitwise.md => 11788-10511-bitwise.md} (100%) rename changelog.d/{PENDING-10718-array-rmw.md => 11788-10718-array-rmw.md} (100%) diff --git a/changelog.d/PENDING-10511-bitwise.md b/changelog.d/11788-10511-bitwise.md similarity index 100% rename from changelog.d/PENDING-10511-bitwise.md rename to changelog.d/11788-10511-bitwise.md diff --git a/changelog.d/PENDING-10718-array-rmw.md b/changelog.d/11788-10718-array-rmw.md similarity index 100% rename from changelog.d/PENDING-10718-array-rmw.md rename to changelog.d/11788-10718-array-rmw.md