From c0bb21897acc3453c42d7750498ea40de6e1b945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 08:46:48 +0200 Subject: [PATCH 1/9] =?UTF-8?q?perf(codegen):=20repsel=204a.0=20=E2=80=94?= =?UTF-8?q?=20numeric-proven=20logical=20selections=20+=20array-guard=20LL?= =?UTF-8?q?VM=20attrs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inference repair for the #6904 histogram shape ((counts[v] || 0) + 1): - is_numeric_expr gains the missing Expr::Logical arm (numeric iff BOTH operands numeric — string/union/bigint operands keep the dynamic path, preserving the 1 + "foo" concat hazard). - expr_may_return_boxed_value_from_raw_f64_fallback gains the matching Logical arm (either operand), so every real-double consumer still inserts its coerce/js_is_truthy on the hazard path; the runtime numeric set guard's is_numeric_value_bits leg keeps a passed-through undefined routing to the boxed fallback (hole-vs-undefined stays byte-exact). - number-context Logical lowering (lower_arithmetic_operand): &&/|| test a coerced real double with a bare fcmp one and phi real doubles; ?? keeps its nullish test on the UNCOERCED left value (NaN ?? x must stay NaN while undefined ?? x is x) and coerces only on the pass-through edge. Kills js_is_truthy + js_dynamic_string_or_number_add + the site js_number_coerce from the histogram inner loop. - expr_produces_canonical_raw_f64: structural proof that a lowered value is a canonical raw f64 (literals, non-BigInt arithmetic chains, Math.*, NumberCoerce, canonical Logicals) — consumed by 4a.1's store tiers. - helper_decl_attrs group #4 (nounwind willreturn) for the five array index/push guards and js_array_numeric_value_to_raw_f64 — each audited: no js_throw reachable, loops bounded by the 16M sanity caps. NOT readonly (first-touch rebuild writes, feedback-mode observation writes, registry lock words) and no argmem (NaN-box args, #6082). Gap test covers hole/explicit-undefined through ||/&&/??, NaN-vs-undefined under ??, -0 truthiness and identity, short-circuit effects, string/union/ bigint operands, and undefined stored through a numeric-array set. --- crates/perry-codegen/src/expr/binary.rs | 129 +++++++++++++++++- crates/perry-codegen/src/module.rs | 38 ++++++ crates/perry-codegen/src/type_analysis.rs | 3 +- .../src/type_analysis/numeric.rs | 111 +++++++++++++++ crates/perry-codegen/src/type_analysis/pod.rs | 12 ++ .../test_gap_repsel_p4a_logical_numeric.ts | 127 +++++++++++++++++ 6 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_repsel_p4a_logical_numeric.ts diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 63199c2b65..795410ab1d 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -5,7 +5,7 @@ //! `lower_expr`'s outer dispatch. use anyhow::Result; -use perry_hir::{BinaryOp, Expr}; +use perry_hir::{BinaryOp, Expr, LogicalOp}; use crate::lower_string_method::{ flatten_string_add_chain, lower_string_coerce_concat, lower_string_concat, @@ -25,6 +25,27 @@ use crate::types::{DOUBLE, I1, I128, I32, I64}; use super::{is_known_finite, lower_expr, FnCtx}; fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, bool)> { + // Repsel Phase 4a.0 (#6904): a numeric-proven `a || b` / `a && b` / + // `a ?? b` consumed as an arithmetic operand lowers with BOTH sides in + // number context, so the selection is a real-double diamond (`fcmp one` + + // phi — SimplifyCFG folds it to a `select`) instead of a boxed + // `js_is_truthy` dispatch whose merged value then needs a site + // `js_number_coerce`. This is the `(counts[v] || 0) + 1` histogram shape. + // + // Early coercion is semantics-preserving here because the consumer is an + // arithmetic operand: every value the coerced test can misclassify + // relative to JS truthiness under HONEST types is `undefined` (a raw-f64 + // read's hole fallback), and ToNumber(undefined) = NaN is falsy exactly + // like `undefined`; the passed-through value is coerced by the consumer + // regardless. `??` keeps its nullish test on the UNCOERCED left value — + // a coerced hole (NaN) is indistinguishable from a stored NaN, but + // `NaN ?? x` is NaN while `undefined ?? x` is `x`. + if let Expr::Logical { op, left, right } = expr { + if crate::type_analysis::is_numeric_expr(ctx, expr) { + let value = lower_numeric_logical_for_number_context(ctx, *op, left, right)?; + return Ok((value, true)); + } + } if expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr) { if let Some(value) = super::property_get::lower_raw_f64_class_field_get_for_number_context(ctx, expr)? @@ -52,6 +73,112 @@ fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, Ok((lower_expr(ctx, expr)?, false)) } +/// Lower an operand in number context: route through +/// [`lower_arithmetic_operand`], then apply the same residual-coercion rule +/// the binary arithmetic path uses — the result is ALWAYS a real (canonical) +/// numeric double, never a NaN-boxed value. +fn lower_operand_as_number(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { + let (raw, fallback_coerced) = lower_arithmetic_operand(ctx, expr)?; + let numeric = crate::type_analysis::is_numeric_expr(ctx, expr); + let needs_coerce = !fallback_coerced + && (!numeric || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr)); + if needs_coerce { + Ok(ctx + .block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &raw)])) + } else { + Ok(raw) + } +} + +/// Repsel Phase 4a.0: number-context lowering of a numeric-proven logical +/// selection (see the caller comment in [`lower_arithmetic_operand`]). +/// +/// `&&` / `||`: the left side is lowered in number context (a real double), +/// so its truthiness test is a bare `fcmp one l, 0.0` — falsy is exactly +/// {`+0`, `-0`, NaN}, and the values that JS-truthiness could disagree on +/// (boxed `undefined` from a hole fallback) have already been coerced to NaN +/// (falsy — identical verdict to `undefined`). Both phi inputs are real +/// doubles, so the merged value feeds `fadd`/`fmul`/… with no further +/// dispatch. +/// +/// `??`: the nullish test runs on the UNCOERCED left value (`bits == +/// TAG_NULL | TAG_UNDEFINED`); the pass-through edge then coerces (only when +/// the operand carries the boxed-fallback hazard), keeping `NaN ?? x` = NaN +/// vs `undefined ?? x` = `x` byte-exact. +fn lower_numeric_logical_for_number_context( + ctx: &mut FnCtx<'_>, + op: LogicalOp, + left: &Expr, + right: &Expr, +) -> Result { + if matches!(op, LogicalOp::Coalesce) { + let l_boxed = lower_expr(ctx, left)?; + let is_nullish = { + let blk = ctx.block(); + let l_bits = blk.bitcast_double_to_i64(&l_boxed); + let is_null = blk.icmp_eq(I64, &l_bits, crate::nanbox::TAG_NULL_I64); + let is_undef = blk.icmp_eq(I64, &l_bits, crate::nanbox::TAG_UNDEFINED_I64); + blk.or(I1, &is_null, &is_undef) + }; + let right_idx = ctx.new_block("numlog.coalesce.right"); + let keep_idx = ctx.new_block("numlog.coalesce.keep"); + let merge_idx = ctx.new_block("numlog.coalesce.merge"); + let right_label = ctx.block_label(right_idx); + let keep_label = ctx.block_label(keep_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&is_nullish, &right_label, &keep_label); + + ctx.current_block = right_idx; + let r = lower_operand_as_number(ctx, right)?; + let r_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = keep_idx; + // Non-nullish left: coerce only when the operand can surface a boxed + // value (e.g. an INT32-boxed number from a read fallback). A plain + // proven double passes through untouched. + let l_num = if expr_may_return_boxed_value_from_raw_f64_fallback(ctx, left) { + ctx.block() + .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &l_boxed)]) + } else { + l_boxed + }; + let keep_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + return Ok(ctx + .block() + .phi(DOUBLE, &[(&r, &r_end), (&l_num, &keep_end)])); + } + + let l = lower_operand_as_number(ctx, left)?; + let l_bool = ctx.block().fcmp("one", &l, "0.0"); + let l_end = ctx.block().label.clone(); + + let then_idx = ctx.new_block("numlog.then"); + let merge_idx = ctx.new_block("numlog.merge"); + let then_label = ctx.block_label(then_idx); + let merge_label = ctx.block_label(merge_idx); + match op { + // a && b: truthy left evaluates the right side; falsy left is the + // result. + LogicalOp::And => ctx.block().cond_br(&l_bool, &then_label, &merge_label), + // a || b: truthy left is the result; falsy left evaluates the right. + LogicalOp::Or => ctx.block().cond_br(&l_bool, &merge_label, &then_label), + LogicalOp::Coalesce => unreachable!("handled above"), + } + + ctx.current_block = then_idx; + let r = lower_operand_as_number(ctx, right)?; + let r_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + Ok(ctx.block().phi(DOUBLE, &[(&l, &l_end), (&r, &r_end)])) +} + fn small_bigint_literal_value(expr: &Expr) -> Option { let Expr::BigInt(raw) = expr else { return None; diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 761f21f4f9..83e51edf17 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -134,6 +134,24 @@ fn helper_decl_attrs(name: &str) -> &'static str { // (js_bigint_is_zero via clean_bigint_ptr, pure bit cleanup). No // registry/lock access, no allocation, no throw, no writes. "js_is_truthy" => " #3", + // NOUNWIND+WILLRETURN only (#4, repsel Phase 4a.0) — each verified + // (`typed_feedback.rs` / `array/header.rs`): no `js_throw` (longjmp) + // anywhere in the body, every loop bounded by the 16M length/capacity + // sanity caps, no allocation, no GC trigger. They are NOT readonly: + // the numeric guards' first-touch path REBUILDS unmarked arrays into + // raw-f64 layout (slot writes + flag store), feedback mode + // (`PERRY_TYPED_FEEDBACK`, a runtime env check) records observations, + // and `js_array_numeric_value_to_raw_f64`'s ClassRef probe takes + // registry RwLock reads (a lock word write). #6082 trap notes apply: + // argmem is unsound for NaN-box args, and `willreturn` is only + // admissible because these helpers cannot reach `js_throw` — any + // divergence is a Rust panic-abort, which never resumes the program. + "js_typed_feedback_plain_array_index_get_guard" + | "js_typed_feedback_numeric_array_index_get_guard" + | "js_typed_feedback_plain_array_index_set_guard" + | "js_typed_feedback_numeric_array_index_set_guard" + | "js_typed_feedback_numeric_array_push_guard" + | "js_array_numeric_value_to_raw_f64" => " #4", _ => "", } } @@ -474,10 +492,12 @@ impl LlModule { // above). See `helper_decl_attrs` for the audit invariants. let mut used_pure = false; let mut used_readonly = false; + let mut used_nounwind_willreturn = false; for name in &self.declared_names { match helper_decl_attrs(name) { " #2" => used_pure = true, " #3" => used_readonly = true, + " #4" => used_nounwind_willreturn = true, _ => {} } } @@ -487,6 +507,9 @@ impl LlModule { if used_readonly { ir.push_str("\nattributes #3 = { nounwind willreturn readonly }\n"); } + if used_nounwind_willreturn { + ir.push_str("\nattributes #4 = { nounwind willreturn }\n"); + } // Issue #52: `!0 = !{}` referenced by `!invariant.load !0`, plus the // buffer alias-scope metadata. LICM/GVN hoist invariant loads out of // loops only with these present. @@ -749,6 +772,11 @@ mod tests { m.declare_function("js_nanbox_get_pointer", I64, &[DOUBLE]); m.declare_function("js_is_truthy", I32, &[DOUBLE]); m.declare_function("js_nanbox_string", DOUBLE, &[I64]); + m.declare_function( + "js_typed_feedback_numeric_array_index_get_guard", + I32, + &[I64, DOUBLE, I32, I32], + ); let f = m.define_function("main", I32, vec![]); f.create_block("entry").ret(I32, "0"); @@ -776,6 +804,15 @@ mod tests { .count(), 1 ); + // Repsel 4a.0: the array-index guards carry #4 (nounwind willreturn, + // no memory attribute — the first-touch path rebuilds raw-f64 layout). + assert!(ir.contains( + "declare i32 @js_typed_feedback_numeric_array_index_get_guard(i64, double, i32, i32) #4" + )); + assert_eq!( + ir.matches("attributes #4 = { nounwind willreturn }").count(), + 1 + ); // No setjmp declared → the setjmp-only groups stay out. assert!(!ir.contains("attributes #0")); assert!(!ir.contains("attributes #1")); @@ -792,6 +829,7 @@ mod tests { let ir = m.to_ir(); assert!(!ir.contains("attributes #2")); assert!(!ir.contains("attributes #3")); + assert!(!ir.contains("attributes #4")); } #[test] diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index e223b81963..e6c4b85b4a 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -32,7 +32,8 @@ mod refine; mod strings; pub(crate) use numeric::{ - is_bigint_expr, is_bool_expr, is_integer_valued_expr, is_numeric_expr, is_provably_not_bigint, + expr_produces_canonical_raw_f64, is_bigint_expr, is_bool_expr, is_integer_valued_expr, + is_numeric_expr, is_provably_not_bigint, }; pub(crate) use pod::{ add_operands_have_pod_materialization_hazard, diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 3c16b235d7..c61bf51fdb 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -198,6 +198,30 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // Explicit numeric-coercion node — lowers to `js_number_coerce`, // which always yields a clean f64. Expr::NumberCoerce(_) => true, + // `a || b` / `a && b` / `a ?? b` select ONE OF THE OPERAND VALUES — + // never a synthesized value — so the result is numeric when BOTH + // operands are (repsel Phase 4a.0, #6904: `(counts[v] || 0) + 1` + // previously routed the whole Add through + // `js_dynamic_string_or_number_add`). + // + // Soundness notes: + // * A possibly-string / union / bool operand fails `is_numeric_expr` + // on that side, so `x || "fallback"` and `s && n` stay non-numeric + // (the `1 + "foo"` concat hazard keeps the dynamic-add bail). + // * A proven-numeric operand can still surface a BOXED value at + // runtime through a raw-f64 array/field read's cold fallback (a + // hole reads `undefined`). That hazard is tracked separately by + // `expr_may_return_boxed_value_from_raw_f64_fallback`, which gained + // the matching Logical arm — every consumer of `is_numeric_expr` + // that needs a REAL double (truthiness fcmp, fadd operands, raw + // stores) already consults it and inserts `js_number_coerce` / + // `js_is_truthy` on that path, and the runtime numeric-array SET + // guard independently rejects non-numeric VALUES, so a passed- + // through `undefined` still stores as `undefined` via the boxed + // fallback (hole-vs-undefined observability is preserved). + Expr::Logical { left, right, .. } => { + is_numeric_expr(ctx, left) && is_numeric_expr(ctx, right) + } // `obj.field` where the field is declared as `number` on the // owning class. Without this, `this.value + 1` in a hot loop // wraps the field load in `js_number_coerce` which prevents @@ -332,6 +356,93 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } +/// Repsel Phase 4a.0 (#6904): statically prove that an expression's LOWERED +/// value is a **canonical raw f64** — a real machine double whose bit pattern +/// is never a NaN-box tag (`0x7FF9..=0x7FFF` upper 16 with a set quiet-NaN +/// payload in tag space). Such a value may be stored into a raw-f64 numeric +/// array slot verbatim, skipping the `js_array_numeric_value_to_raw_f64` +/// canonicalization call (whose INT32-unbox + `is_class_id_registered` +/// registry probe cannot fire for a value this predicate admits). +/// +/// SOUNDNESS CONTRACT — a `true` return authorizes a raw slot store with no +/// runtime value check, so the value must be a real number for EVERY input: +/// * never NaN-boxed (INT32/STRING/POINTER/BIGINT/UNDEFINED/HOLE tags), and +/// * any NaN it produces must carry a non-tag payload. Arithmetic on +/// canonical inputs propagates canonical NaNs (hardware qNaN `0x7FF8…`, or +/// a sign-flipped `0xFFF8…` through `fneg` — both outside tag space), and +/// every operand feeding these lowerings is itself coerced/canonical, so +/// the property holds inductively. +/// +/// Structurally admitted: +/// * numeric literals; +/// * `Binary` arithmetic/bitwise when the whole node is `is_numeric_expr` AND +/// `is_provably_not_bigint` (a possibly-BigInt chain routes through the +/// BIGINT-boxed dynamic helpers — those results are NaN-boxed pointers); +/// * `Unary` Neg/Pos/BitNot over a non-BigInt operand; +/// * `Update` (++/--) when numeric per `is_numeric_expr`; +/// * the `Math.*` family / `Date.now` (Rust-computed f64s); +/// * explicit `NumberCoerce`; +/// * `Logical` selections whose BOTH operands are themselves canonical. +/// +/// Deliberately NOT admitted: `LocalGet` (a Number-typed local can hold an +/// INT32-boxed value assigned from a boxed read fallback), reads +/// (`IndexGet`/`PropertyGet` — cold fallbacks return boxed bits), and calls. +pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool { + match e { + Expr::Integer(_) | Expr::Number(_) => true, + Expr::Binary { .. } => is_numeric_expr(ctx, e) && is_provably_not_bigint(ctx, e), + Expr::Unary { op, operand } => { + matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::BitNot) + && is_provably_not_bigint(ctx, operand) + } + Expr::Update { .. } => is_numeric_expr(ctx, e), + Expr::NumberCoerce(_) => true, + Expr::Logical { left, right, .. } => { + expr_produces_canonical_raw_f64(ctx, left) && expr_produces_canonical_raw_f64(ctx, right) + } + Expr::MathFloor(..) + | Expr::MathCeil(..) + | Expr::MathRound(..) + | Expr::MathTrunc(..) + | Expr::MathSign(..) + | Expr::MathAbs(..) + | Expr::MathSqrt(..) + | Expr::MathLog(..) + | Expr::MathLog2(..) + | Expr::MathLog10(..) + | Expr::MathPow(..) + | Expr::MathMin(..) + | Expr::MathMax(..) + | Expr::MathMinSpread(..) + | Expr::MathMaxSpread(..) + | Expr::MathImul(..) + | Expr::MathRandom + | Expr::MathSin(..) + | Expr::MathCos(..) + | Expr::MathTan(..) + | Expr::MathAsin(..) + | Expr::MathAcos(..) + | Expr::MathAtan(..) + | Expr::MathAtan2(..) + | Expr::MathCbrt(..) + | Expr::MathHypot(..) + | Expr::MathFround(..) + | Expr::MathF16round(..) + | Expr::MathClz32(..) + | Expr::MathExpm1(..) + | Expr::MathLog1p(..) + | Expr::MathSinh(..) + | Expr::MathCosh(..) + | Expr::MathTanh(..) + | Expr::MathAsinh(..) + | Expr::MathAcosh(..) + | Expr::MathAtanh(..) + | Expr::MathExp(..) + | Expr::DateNow => true, + _ => false, + } +} + /// Statically prove that an expression's runtime value can **never** be a /// BigInt. /// diff --git a/crates/perry-codegen/src/type_analysis/pod.rs b/crates/perry-codegen/src/type_analysis/pod.rs index 8337b84c0d..e91dbabfdd 100644 --- a/crates/perry-codegen/src/type_analysis/pod.rs +++ b/crates/perry-codegen/src/type_analysis/pod.rs @@ -416,6 +416,18 @@ pub(crate) fn expr_may_return_boxed_value_from_raw_f64_fallback( Expr::IndexGet { object, .. } => static_type_of(ctx, object) .as_ref() .is_some_and(type_has_numeric_pointer_free_array_layout_for_fallback), + // Repsel Phase 4a.0: `a || b` / `a && b` / `a ?? b` pass ONE operand + // value through, so the result carries the boxed-fallback hazard when + // EITHER operand does (`counts[v] || 0` can surface the read's boxed + // `undefined` through the `&&`/`??` value edge). Must stay in lockstep + // with `is_numeric_expr`'s Logical arm: everything that treats a + // proven-numeric Logical as a real double consults this predicate to + // decide whether a `js_number_coerce` / `js_is_truthy` is still + // needed. + Expr::Logical { left, right, .. } => { + expr_may_return_boxed_value_from_raw_f64_fallback(ctx, left) + || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, right) + } _ => false, } } diff --git a/test-files/test_gap_repsel_p4a_logical_numeric.ts b/test-files/test_gap_repsel_p4a_logical_numeric.ts new file mode 100644 index 0000000000..a9b06045b0 --- /dev/null +++ b/test-files/test_gap_repsel_p4a_logical_numeric.ts @@ -0,0 +1,127 @@ +// Test: repsel Phase 4a.0 — numeric-proven logical selections (`a || b`, +// `a && b`, `a ?? b`) in arithmetic, condition, and element-store contexts +// (#6904 histogram shape). Validated byte-for-byte against +// `node --experimental-strip-types`. +// +// Edges: hole vs explicit-undefined reads through `|| 0` / `?? 0` / `&& x`, +// NaN vs undefined under `??` (the nullish test must see the UNCOERCED +// value), -0 truthiness and identity, short-circuit side effects, string / +// union / bigint operands staying off the numeric fast path, and a +// passed-through `undefined` stored into a `number[]` element (must stay +// `undefined`, never NaN). + +// --- histogram shape: (counts[v] || 0) + 1 over a masked index --- +function histogram(data: number[], size: number): number[] { + const counts: number[] = new Array(size); + const mask = size - 1; + for (let i = 0; i < data.length; i++) { + const v = data[i] & mask; + counts[v] = (counts[v] || 0) + 1; + } + return counts; +} +const data: number[] = []; +// Park-Miller LCG: every intermediate stays below 2^53, so the sequence is +// exact in f64 on any engine (a multiplier that overflows 2^53 would leave +// the values implementation-rounding-sensitive). +let seed = 12345; +for (let i = 0; i < 1000; i++) { + seed = (seed * 48271) % 2147483647; + data.push(seed); +} +const h = histogram(data, 16); +console.log(h.join(",")); +let total = 0; +for (let i = 0; i < h.length; i++) total += h[i] || 0; +console.log(total); + +// --- hole vs explicit undefined through || / ?? / && --- +const holey: number[] = new Array(5); +holey[1] = 0; +holey[2] = NaN; +holey[3] = -0; +console.log(holey[0] || 7); // hole reads undefined -> falsy -> 7 +console.log(holey[1] || 7); // 0 -> 7 +console.log(holey[2] || 7); // NaN -> 7 +console.log(Object.is(holey[3] || 7, 7)); // -0 falsy -> 7 +console.log(holey[0] ?? 7); // undefined -> 7 +console.log(holey[2] ?? 7); // NaN is NOT nullish -> NaN +console.log(Object.is(holey[3] ?? 7, -0)); // -0 is NOT nullish -> -0 +console.log(holey[0] && 7); // undefined && -> undefined +console.log((holey[0] || 0) + 1); // 1 +console.log((holey[2] ?? 0) + 1); // NaN +console.log((holey[0] ?? 0) + 1); // 1 +if (holey[0] || 0) { + console.log("truthy"); +} else { + console.log("falsy"); +} + +// hole-vs-undefined observability must be untouched +console.log(0 in holey, 1 in holey); +console.log(Object.keys(holey).join(",")); +console.log(JSON.stringify(holey)); + +// --- passed-through undefined stored into number[] slots --- +const dst: number[] = new Array(3); +const src: number[] = new Array(3); +src[0] = 5; +dst[0] = src[1] && 9; // undefined && 9 -> undefined must be stored +dst[1] = src[0] && 9; // 5 && 9 -> 9 +console.log(dst[0], dst[1]); +console.log(JSON.stringify(dst)); +console.log(0 in dst, 2 in dst); + +// --- arithmetic results through logical in number stores --- +const out: number[] = []; +out.push((src[0] || 0) * 2); +out[1] = (src[1] || 0) - 1; +out[2] = -(src[0] ?? 0); +console.log(out.join(",")); + +// --- short-circuit side effects must be preserved --- +let calls = 0; +function eff(): number { + calls++; + return 42; +} +const nums: number[] = [3]; +console.log(nums[0] || eff()); // 3; eff NOT called +console.log(calls); // 0 +console.log(nums[1] || eff()); // undefined -> 42 +console.log(calls); // 1 +console.log((nums[0] && eff()) + 1); // 43 +console.log(calls); // 2 +console.log((nums[1] ?? eff()) + 1); // 43 +console.log(calls); // 3 +console.log((nums[0] ?? eff()) + 1); // 4; eff not called +console.log(calls); // 3 + +// --- string / union operands keep JS semantics (concat, not fadd) --- +const name: string = ""; +console.log(name || 5); // "" falsy -> 5 +console.log(5 || name); // 5 +const mixed: any = "abc"; +console.log(mixed || 0); // "abc" +console.log((src[0] || 0) + "s"); // "5s" string concat +console.log(("a" && 3) + 1); // "a" truthy -> 3 -> 4 + +function pick(flag: boolean): number | undefined { + return flag ? 5 : undefined; +} +const u = pick(false); +console.log((u || 0) + 1); // 1 + +// --- bigint operands must stay off the numeric fast path --- +const b1: bigint = 5n; +const b2: bigint = 0n; +console.log(b1 || 99n); +console.log(b2 || 99n); +console.log((b1 && 3n) * 2n); +console.log(b2 ?? 7n); + +// --- -0 identity through || / ?? in number context --- +const negz: number = -0; +console.log(Object.is(negz || 0, 0)); // -0 falsy -> +0 (right side) +console.log(Object.is(negz ?? 1, -0)); // -0 not nullish -> -0 +console.log(1 / (negz || Infinity)); // -0 falsy -> Infinity -> 0 From 866e15dd746c901c50f333fd1f6564c66fc86366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 09:36:39 +0200 Subject: [PATCH 2/9] =?UTF-8?q?perf(runtime):=20repsel=204a.2=20=E2=80=94?= =?UTF-8?q?=20raw-f64-or-holes=20flag=20maintenance=20+=20stale-head=20ref?= =?UTF-8?q?resh=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime half of the #6904 holes axis: - js_array_set_f64_extend's sparse gap-fill goes through a hole-aware note (TAG_HOLE is part of the raw-f64-or-holes invariant, so it must not run the non-numeric layout clear that permanently demoted every sparsely extended numeric array to the O(n) verify walk). When the array carried a raw-f64 invariant before the extend and the stored value is numeric, the flags transition dense->holes (demote_array_raw_f64_dense_to_holes) instead of clearing. - js_array_refresh_local_head: follows the growth/GC forwarding chain of a POINTER-tagged array head and returns the re-boxed live head (identity for every other input). Consumed by the guard tiers' cold arms to self-heal stale caller bindings: a Phase 2 specialized-ABI callee that grows a caller-allocated array updates only its own param slot, so the caller's binding kept a pre-growth forwarded stub forever — every structural guard (inline tiers, packed-loop entry guards) rejects forwarded heads by design, which silently pinned such receivers to the boxed chain-following fallback on every access. - numeric_array_index_guard_for_tests + unit tests: push-built arrays verify and keep the dense flag; sparse extend keeps raw-f64-or-holes (numeric value) or clears both flags (non-numeric value); refresh follows growth forwarding and is identity elsewhere. Gap test covers new-Array mid-fill reads/writes, dense->sparse transitions, hole-vs-undefined observability (in/Object.keys/ JSON.stringify), -0/NaN edges through the hole-default consumers, and growth boundaries. --- crates/perry-runtime/src/array/header.rs | 75 +++++++++++ crates/perry-runtime/src/array/indexing.rs | 21 ++- crates/perry-runtime/src/array/tests.rs | 127 +++++++++++++++++++ crates/perry-runtime/src/typed_feedback.rs | 9 ++ test-files/test_gap_repsel_p4a_holes_axis.ts | 63 +++++++++ 5 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 test-files/test_gap_repsel_p4a_holes_axis.ts diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 91bca4728d..11f35e6f2c 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -958,6 +958,79 @@ pub(crate) unsafe fn mark_array_raw_f64_holes_fresh(arr: *const ArrayHeader) { set_array_raw_f64_holes_flag(arr); } +/// Repsel 4a.2 (#6904): either raw-f64 invariant bit — the O(1) proof the +/// hole-tolerant fast tiers key on. +#[inline] +pub(crate) unsafe fn array_has_raw_f64_layout_or_holes(arr: *const ArrayHeader) -> bool { + array_gc_header(arr).is_some_and(|header| { + (*header)._reserved + & (crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES) + != 0 + }) +} + +/// Repsel 4a.2 (#6904): hole-fill store for a sparse-extend gap. `TAG_HOLE` +/// is part of the raw-f64-or-holes invariant, so this deliberately does NOT +/// run the numeric-layout clear that [`note_array_slot`] applies to +/// non-numeric values (which permanently demoted every sparsely-extended +/// numeric array to the O(n) verify walk). Layout note + write barrier still +/// apply (TAG_HOLE is a non-pointer sentinel). +#[inline] +pub(crate) unsafe fn note_array_hole_fill_slot(arr: *mut ArrayHeader, index: usize) { + // GC_STORE_AUDIT(BARRIERED): TAG_HOLE sentinel store, layout-noted and barriered below. + std::ptr::write(array_elements_ptr(arr).add(index), crate::value::TAG_HOLE); + crate::gc::layout_note_slot(arr as usize, index, crate::value::TAG_HOLE); + let slot = array_elements_ptr(arr).add(index) as usize; + crate::gc::runtime_write_barrier_slot(arr as usize, slot, crate::value::TAG_HOLE); +} + +/// Repsel 4a.2 (#6904): follow the growth/GC forwarding chain of a +/// POINTER-tagged array head and return the re-boxed LIVE head; every other +/// input (non-pointer tags, handle-band ids, non-arrays, already-live heads) +/// is returned unchanged. +/// +/// Consumed by the inline guard tiers' COLD arms: a caller-held stale head +/// (canonically: a Phase 2 specialized-ABI callee grew an array the CALLER +/// allocated — the callee's growth write-backs update the callee's own param +/// slot, so the caller's binding keeps the pre-growth stub forever) fails +/// every structural guard by design, which pinned such receivers to the +/// boxed fallback on EVERY access. The cold arm calls this once, stores the +/// repaired head back into the receiver's local slot, and every later +/// iteration re-loads the live head and takes the inline tier. Semantics are +/// unchanged — forwarding is transparent, this only re-points the binding at +/// the same JS object's live storage. +#[no_mangle] +pub extern "C" fn js_array_refresh_local_head(value: f64) -> f64 { + let bits = value.to_bits(); + if bits & crate::value::TAG_MASK != crate::value::POINTER_TAG { + return value; + } + let raw = (bits & crate::value::POINTER_MASK) as usize; + // Handle-band ids and implausible addresses pass through untouched. + if !crate::value::addr_class::is_plausible_heap_addr(raw) { + return value; + } + let cleaned = clean_arr_ptr(raw as *const ArrayHeader); + if cleaned.is_null() || cleaned as usize == raw { + return value; + } + f64::from_bits(crate::value::POINTER_TAG | (cleaned as u64 & crate::value::POINTER_MASK)) +} + +/// Repsel 4a.2 (#6904): a raw-f64(-or-holes) array that just gap-filled a +/// sparse extend with a numeric value keeps the verified raw-f64-or-holes +/// invariant — but holes now exist, so the DENSE flag must drop while the +/// HOLES flag records the invariant (previously this transition cleared both +/// flags, sending every later access through the O(n) rebuild walk). +#[inline] +pub(crate) unsafe fn demote_array_raw_f64_dense_to_holes(arr: *mut ArrayHeader) { + if let Some(header) = array_gc_header(arr) { + (*header)._reserved &= !crate::gc::GC_ARRAY_RAW_F64_LAYOUT; + (*header)._reserved |= crate::gc::GC_ARRAY_RAW_F64_HOLES; + crate::typed_feedback::invalidate_representation_change(arr as usize); + } +} + pub(crate) unsafe fn mark_array_as_arguments_object(arr: *const ArrayHeader) { if let Some(header) = array_gc_header(arr) { (*header)._reserved |= crate::gc::GC_ARRAY_ARGUMENTS_OBJECT; @@ -1426,6 +1499,8 @@ static KEEP_JS_ARRAY_NOTE_NUMERIC_WRITE: extern "C" fn(*mut ArrayHeader, u64) = #[used] static KEEP_JS_ARRAY_IS_NUMERIC_F64_LAYOUT: extern "C" fn(*const ArrayHeader) -> i32 = js_array_is_numeric_f64_layout; +#[used] +static KEEP_JS_ARRAY_REFRESH_LOCAL_HEAD: extern "C" fn(f64) -> f64 = js_array_refresh_local_head; /// Calculate the byte size for an array with N elements capacity #[inline] diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 6f2887bdb1..39bc53e2f7 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -1095,12 +1095,19 @@ pub extern "C" fn js_array_set_f64_extend( // arrays serialized as `[0, 0, ...]` instead of `[null, null, // ...]`. Read paths translate TAG_HOLE → TAG_UNDEFINED via // `js_array_get_f64`'s post-#323 hole handling. + // + // Repsel 4a.2 (#6904): the gap fill goes through the hole-aware + // note — TAG_HOLE is part of the raw-f64-or-holes invariant, so it + // must not clear the layout flags the way a genuine non-numeric + // store does. When the array carried a raw-f64 invariant before the + // extend AND the stored value is numeric, the invariant still holds + // afterwards: record it (dense drops to holes) instead of demoting + // to the permanent O(n) verify walk. + let had_raw_layout = crate::array::header::array_has_raw_f64_layout_or_holes(arr); let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - let hole = f64::from_bits(crate::value::TAG_HOLE); for i in length..index { - // GC_STORE_AUDIT(BARRIERED): sparse gap sentinel is immediately recorded via note_array_slot. - ptr::write(elements_ptr.add(i as usize), hole); - note_array_slot(arr, i as usize, crate::value::TAG_HOLE); + // GC_STORE_AUDIT(BARRIERED): sparse gap sentinel is layout-noted + barriered by the hole-aware note. + crate::array::header::note_array_hole_fill_slot(arr, i as usize); } // Set the value @@ -1110,6 +1117,12 @@ pub extern "C" fn js_array_set_f64_extend( ptr::write(elements_ptr.add(index as usize), value); note_array_slot(arr, index as usize, value_bits); (*arr).length = new_length; + if had_raw_layout + && index > length + && crate::array::header::value_bits_are_numeric(value_bits) + { + crate::array::header::demote_array_raw_f64_dense_to_holes(arr); + } arr } diff --git a/crates/perry-runtime/src/array/tests.rs b/crates/perry-runtime/src/array/tests.rs index e413138d41..ca4afff639 100644 --- a/crates/perry-runtime/src/array/tests.rs +++ b/crates/perry-runtime/src/array/tests.rs @@ -1512,3 +1512,130 @@ fn join_accepts_heap_string_tagged_elements() { assert_eq!(s, "alpha|beta"); } } + +#[test] +fn refresh_local_head_follows_growth_forwarding() { + // Repsel 4a.2 (#6904): a caller-held pre-grow head must refresh to the + // live head; live heads and non-pointer values pass through unchanged. + unsafe { + let arr = js_array_alloc(2); + let stale = arr; + let mut cur = arr; + for i in 0..64 { + cur = js_array_push_f64(cur, i as f64); + } + // Growth happened: the original head is a forwarded stub. + let hdr = (stale as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + assert_ne!( + (*hdr).gc_flags & crate::gc::GC_FLAG_FORWARDED, + 0, + "expected the pre-grow head to be forwarded" + ); + let stale_box = + f64::from_bits(crate::value::POINTER_TAG | (stale as u64 & crate::value::POINTER_MASK)); + let fresh = crate::array::header::js_array_refresh_local_head(stale_box); + let fresh_addr = (fresh.to_bits() & crate::value::POINTER_MASK) as usize; + assert_eq!( + fresh_addr, cur as usize, + "refresh must land on the live head" + ); + // Idempotent on the live head. + let live_box = + f64::from_bits(crate::value::POINTER_TAG | (cur as u64 & crate::value::POINTER_MASK)); + assert_eq!( + crate::array::header::js_array_refresh_local_head(live_box).to_bits(), + live_box.to_bits() + ); + // Non-pointer values pass through untouched. + for bits in [ + 42.5f64.to_bits(), + crate::value::TAG_UNDEFINED, + crate::value::TAG_NULL, + ] { + assert_eq!( + crate::array::header::js_array_refresh_local_head(f64::from_bits(bits)).to_bits(), + bits + ); + } + } +} + +#[test] +fn sparse_extend_keeps_raw_f64_holes_invariant() { + // Repsel 4a.2 (#6904): a sparse extend on a raw-f64 array must transition + // dense -> holes (not clear both flags into the permanent O(n) walk). + unsafe { + let mut arr = js_array_alloc(2); + arr = js_array_push_f64(arr, 1.5); + arr = js_array_push_f64(arr, 2.5); + assert_eq!(js_array_is_numeric_f64_layout(arr), 1); + arr = js_array_set_f64_extend(arr, 9, 7.5); + let hdr = (arr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + let reserved = (*hdr)._reserved; + assert_eq!( + reserved & crate::gc::GC_ARRAY_RAW_F64_LAYOUT, + 0, + "dense flag must drop once holes exist (reserved={reserved:#x})" + ); + assert_ne!( + reserved & crate::gc::GC_ARRAY_RAW_F64_HOLES, + 0, + "holes flag must record the raw-f64-or-holes invariant (reserved={reserved:#x})" + ); + // Values and hole observability are intact. + assert_eq!(js_array_get_f64(arr, 0), 1.5); + assert_eq!(js_array_get_f64(arr, 9), 7.5); + assert_eq!( + js_array_get_f64(arr, 5).to_bits(), + crate::value::TAG_UNDEFINED + ); + // A non-numeric sparse extend must NOT claim the invariant. + let mut other = js_array_alloc(2); + other = js_array_push_f64(other, 1.0); + assert_eq!(js_array_is_numeric_f64_layout(other), 1); + let s = crate::string::js_string_from_bytes(b"x".as_ptr(), 1); + let s_box = + f64::from_bits(crate::value::STRING_TAG | (s as u64 & crate::value::POINTER_MASK)); + other = js_array_set_f64_extend(other, 6, s_box); + let ohdr = (other as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + assert_eq!( + (*ohdr)._reserved + & (crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES), + 0, + "non-numeric store must clear both raw-f64 flags" + ); + } +} + +#[test] +fn push_built_array_gets_and_keeps_dense_raw_f64_flag() { + unsafe { + let mut arr = js_array_alloc(0); + let mut seed: f64 = 7.0; + for _ in 0..1000 { + seed = (seed * 48271.0) % 2147483647.0; + arr = js_array_push_f64(arr, seed); + } + let probe = js_array_is_numeric_f64_layout(arr); + let header = (arr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + let reserved = (*header)._reserved; + assert_eq!( + probe, 1, + "push-built numeric array should verify raw-f64 (reserved={reserved:#x})" + ); + assert_ne!( + reserved & (crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES), + 0, + "raw-f64 flag should be set after the probe (reserved={reserved:#x})" + ); + // The inner guard the codegen cold arm consults. + assert!( + crate::typed_feedback::numeric_array_index_guard_for_tests( + arr as *const ArrayHeader, + 3, + true + ), + "numeric index guard should admit the push-built array" + ); + } +} diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index fb242f4a6c..bf26086af2 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -1206,6 +1206,15 @@ fn plain_array_index_guard(arr: *const ArrayHeader, index: u32, require_in_bound } } +#[cfg(test)] +pub(crate) fn numeric_array_index_guard_for_tests( + arr: *const ArrayHeader, + index: u32, + require_in_bounds: bool, +) -> bool { + numeric_array_index_guard(arr, index, require_in_bounds) +} + fn numeric_array_index_guard(arr: *const ArrayHeader, index: u32, require_in_bounds: bool) -> bool { if !plain_array_index_guard(arr, index, require_in_bounds) { return false; diff --git a/test-files/test_gap_repsel_p4a_holes_axis.ts b/test-files/test_gap_repsel_p4a_holes_axis.ts new file mode 100644 index 0000000000..c9bb21e315 --- /dev/null +++ b/test-files/test_gap_repsel_p4a_holes_axis.ts @@ -0,0 +1,63 @@ +// Test: repsel Phase 4a.2 — hole-tolerant numeric tier + HOLES-flag +// maintenance + inline sparse-extend growth (#6904 axis). `new Array(n)` +// mid-fill reads/writes, dense->sparse transitions, and every hole-default +// consumer form must stay byte-exact — including hole-vs-undefined +// observability through `in` / `Object.keys` / `JSON.stringify` and the +// -0 / NaN edges of the `|| 0` truthiness form. +// Validated byte-for-byte against `node --experimental-strip-types`. +export {}; + +// --- mid-fill histogram shape over a fresh holey array --- +const c: number[] = new Array(8); +c[3] = (c[3] || 0) + 1; // hole -> 1 +c[3] = (c[3] || 0) + 1; // 1 -> 2 +console.log(JSON.stringify(c), 3 in c, 0 in c); + +// number-context reads over holes: |0, >>>0, arithmetic, compare +console.log(c[0] | 0, c[0] >>> 0, c[0] * 2, c[0] > 0, c[3] | 0); + +// --- sparse extend keeps the invariant + observability --- +const s: number[] = [1, 2]; +s[6] = 7; // gap 2..5 becomes holes +console.log(JSON.stringify(s), s.length, 4 in s, Object.keys(s).join(",")); +let sum = 0; +for (let i = 0; i < s.length; i++) sum += s[i] || 0; +console.log(sum); +s[4] = (s[4] ?? 100) + 1; // hole -> 101 +console.log(s[4], JSON.stringify(s)); + +// --- dense -> sparse -> reads through the hole-tolerant tier --- +const d: number[] = [10, 20, 30]; +d[10] = 40; +console.log(d.length, d[5], d[10], JSON.stringify(d)); +let t2 = 0; +for (let i = 0; i < d.length; i++) t2 += d[i] || 0; +console.log(t2); + +// --- growth boundary: dense appends, then a write beyond capacity --- +const g: number[] = [1]; +for (let i = 1; i <= 40; i++) g[i] = i; +console.log(g.length, g[40], g[17]); +g[100] = 5; // beyond capacity -> runtime grow + gap fill +console.log(g.length, g[99], 99 in g, g[100]); +let t3 = 0; +for (let i = 0; i < g.length; i++) t3 += g[i] || 0; +console.log(t3); + +// --- NaN / -0 stored into a holey array; ||-class consumers stay exact --- +const w: number[] = new Array(4); +w[0] = NaN; +w[1] = -0; +console.log(w[0] || 9, Object.is(w[1] || 9, 9), Object.is(w[1] ?? 9, -0), w[2] ?? 9); +console.log(Object.is(w[0] ?? 5, NaN)); // stored NaN is NOT nullish + +// --- explicit undefined store demotes; hole-vs-undefined stays observable --- +const u: number[] = new Array(3); +u[0] = 1; +(u as any)[1] = undefined; +console.log(JSON.stringify(u), 1 in u, 2 in u, u[1], u[2]); +console.log((u[1] || 3) + (u[2] || 4)); + +// --- iteration / join over holes after the fast tiers ran --- +console.log(w.join("|")); +console.log(s.join("|")); From 904cb7341a91863f73c4138e777fa1bc7fd83ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 09:37:00 +0200 Subject: [PATCH 3/9] =?UTF-8?q?perf(codegen):=20repsel=204a.1+4a.2=20?= =?UTF-8?q?=E2=80=94=20inline=20guard=20tiers=20for=20numeric=20plain-arra?= =?UTF-8?q?y=20read/write/push,=20hole-tolerant=20+=20self-healing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ends the typed-number[]-slower-than-untyped inversion (#6904 recon) in both directions, in non-feedback builds: - READ (lower_guarded_array_index_get): the numeric tier gets the same inline structural guard the plain tier had, plus a raw-f64 proof test on the header word it already loads. Number-context reads (coerce_numeric_fallback) accept dense OR raw-f64-or-holes (0x1080) and canonicalize any NaN payload to the quiet NaN — bit-exact with ToNumber(undefined) for a hole and ToNumber(NaN) for a stored NaN, and PROOF-GATED (only sound under the raw-f64-or-holes invariant). Generic reads stay dense-only with the verbatim raw slot. Guard misses take a COLD out-of-line guard whose first touch rebuilds unmarked arrays. - WRITE (lower_index_set_fast): inline first tier for canonical-raw-f64 RHS (expr_produces_canonical_raw_f64) — array/forwarding/integrity/ descriptor/prototype/sanity checks plus the 0x1080 raw proof, then a bare store double. The extend arm widens from idx==length to any in-capacity extend: inline TAG_HOLE gap fill (pointer-free under the proof, no notes/barriers), length bump, and a branchless dense->holes header transition when a gap was created. Only idx>=capacity pays the runtime grow. - PUSH (array_push.rs): a canonical-numeric push now falls through to the untyped inline tier (bare store + length bump) instead of the 3-call guard+unboxed-push+length shape; non-canonical numeric values keep the runtime-guarded tier (verbatim inline stores of INT32-boxed bits would corrupt the dense raw-f64 invariant). - Store canonicalization skip (4a.1 step 5): canonical-by-construction RHS (literals, non-BigInt arithmetic chains, Math.*, NumberCoerce, canonical Logicals) stores verbatim, dropping the js_array_numeric_value_to_raw_f64 call and its ClassRef registry probe. - COLD-arm self-heal: both tiers call js_array_refresh_local_head on guard miss and store the live head back into the plain local slot (boxed/captured locals excluded), so a stale growth-forwarded binding (specialized-ABI caller-allocated arrays, #6904's 26x pathology) heals on first touch instead of pinning every access to the boxed fallback. Feedback-emission builds keep the previous out-of-line-guard shapes and observation streams unchanged. Structural proof (pre-opt IR, histogram fn): every js_* call now sits in a cold/fallback block; the fast path is header loads + compares + load/select/fadd/store. Gap tests cover typed-vs-untyped behavior across frozen/sealed/descriptor/growth/aliasing/NaN/-0/hole passthrough edges; all byte-exact vs node incl. PERRY_GC_FORCE_EVACUATE=1. --- crates/perry-codegen/src/expr/array_push.rs | 17 ++ crates/perry-codegen/src/expr/index.rs | 240 +++++++++++++++++- crates/perry-codegen/src/expr/index_get.rs | 163 ++++++++++-- crates/perry-codegen/src/expr/index_set.rs | 19 +- .../perry-codegen/src/runtime_decls/arrays.rs | 4 + .../test_gap_repsel_p4a_inline_tiers.ts | 94 +++++++ 6 files changed, 506 insertions(+), 31 deletions(-) create mode 100644 test-files/test_gap_repsel_p4a_inline_tiers.ts diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index 1ea90599b9..5a0e1c21aa 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -86,7 +86,24 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { lower_array_push_value(ctx, value, layout_note_needed, write_barrier_needed)?; let arr_box = lower_expr(ctx, &array_expr)?; + // Repsel 4a.1 (#6904 recon): the guarded numeric push was an + // INVERSION — 3 out-of-line calls (guard + unboxed push + length) + // where the untyped tier below inlines the store. When feedback + // emission is off and the pushed value is canonical-raw-f64 by + // construction, the untyped inline tier is byte-identical for a + // numeric-layout array: the bare `store double` writes canonical + // bits (keeping the raw-f64 invariant with no canonicalization + // call — `array_store_needs_layout_note` already skips the note + // for exactly this array/value class), and every guard the + // runtime tier checked (forwarded / integrity / descriptors / + // capacity) is checked inline before the store. Non-canonical + // numeric values (e.g. a read fallback's INT32-boxed bits) keep + // the runtime-guarded tier: stored verbatim they would corrupt + // the dense raw-f64 invariant. + let keep_guarded_numeric_push = super::typed_feedback_emission_enabled() + || !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value); if require_numeric_layout + && keep_guarded_numeric_push && !ctx.boxed_vars.contains(array_id) && !ctx.closure_captures.contains_key(array_id) && ctx.locals.contains_key(array_id) diff --git a/crates/perry-codegen/src/expr/index.rs b/crates/perry-codegen/src/expr/index.rs index 2544698858..ae62d77845 100644 --- a/crates/perry-codegen/src/expr/index.rs +++ b/crates/perry-codegen/src/expr/index.rs @@ -13,7 +13,7 @@ use crate::nanbox::POINTER_MASK_I64; use crate::native_value::{ BoundsState, BufferAccessMode, LoweredValue, MaterializationReason, NativeRep, SemanticKind, }; -use crate::types::{DOUBLE, I1, I32, I64}; +use crate::types::{DOUBLE, I1, I16, I32, I64, I8}; fn canonicalize_raw_f64_numeric_store_value(blk: &mut LlBlock, value_double: &str) -> String { blk.call( @@ -86,6 +86,10 @@ pub(crate) fn lower_index_set_fast( write_barrier_needed: bool, value_is_numeric: bool, require_numeric_layout: bool, + // Repsel 4a.0: RHS proven canonical-raw-f64 by + // `expr_produces_canonical_raw_f64` — the slot store may skip the + // `js_array_numeric_value_to_raw_f64` canonicalization call entirely. + value_is_canonical_raw_f64: bool, feedback_site_id: &str, ) -> Result<()> { // Capture the local slot for the realloc path. @@ -121,6 +125,109 @@ pub(crate) fn lower_index_set_fast( // rejects dynamic/cross-boundary receivers, lazy arrays, stale forwarded // heads, and corrupt layouts; the fallback then uses boxed JSValue // semantics and writes the returned receiver back to the local slot. + // + // Repsel 4a.1: the numeric WRITE gets an inline first tier mirroring the + // read side — the structural facts (array type, no forwarding, integrity + // + descriptor bits, prototype-chain byte, header sanity) plus the + // `GC_ARRAY_RAW_F64_LAYOUT` dense bit live in two header bytes and one + // sticky global. It only applies when the RHS is canonical-raw-f64 by + // construction (`expr_produces_canonical_raw_f64`): the out-of-line + // guard's remaining job on such values is exactly these header tests + // (its `is_numeric_value_bits(value)` leg is statically true). Guard + // misses fall to the existing out-of-line guard, whose first-touch path + // rebuilds unmarked numeric arrays (setting the dense flag), so the + // steady state is call-free. Feedback-emission builds keep the + // out-of-line guard for observation coverage. + let inline_write_tier = require_numeric_layout + && value_is_canonical_raw_f64 + && !super::typed_feedback_emission_enabled(); + let cold_guard_idx = if inline_write_tier { + Some(ctx.new_block("idxset.guard.cold")) + } else { + None + }; + if inline_write_tier { + let cold_label = ctx.block_label(cold_guard_idx.unwrap()); + let deref_idx = ctx.new_block("idxset.guard.deref"); + let deref_label = ctx.block_label(deref_idx); + { + let blk = ctx.block(); + let tag = blk.lshr(I64, &arr_bits, "48"); + let is_pointer = blk.icmp_eq(I64, &tag, "32765"); // POINTER_TAG + let above_handle_band = blk.icmp_ugt(I64, &arr_handle, "1048575"); + let heap_candidate = blk.and(I1, &is_pointer, &above_handle_band); + blk.cond_br(&heap_candidate, &deref_label, &cold_label); + } + ctx.current_block = deref_idx; + { + let blk = ctx.block(); + let gc_type_addr = blk.sub(I64, &arr_handle, "8"); + let gc_type_ptr = blk.inttoptr(I64, &gc_type_addr); + let gc_type = blk.load(I8, &gc_type_ptr); + let is_array = blk.icmp_eq(I8, &gc_type, "1"); // GC_TYPE_ARRAY + + let gc_flags_addr = blk.sub(I64, &arr_handle, "7"); + let gc_flags_ptr = blk.inttoptr(I64, &gc_flags_addr); + let gc_flags = blk.load(I8, &gc_flags_ptr); + let forwarded_bits = blk.and(I8, &gc_flags, "128"); + let not_forwarded = blk.icmp_eq(I8, &forwarded_bits, "0"); + + // FROZEN(0x1)|SEALED(0x2)|NO_EXTEND(0x4)|ARRAY_DESCRIPTORS(0x400): + // integrity/descriptor-carrying arrays route through the runtime + // (writes may throw in strict mode / dispatch accessor setters). + let reserved_addr = blk.sub(I64, &arr_handle, "6"); + let reserved_ptr = blk.inttoptr(I64, &reserved_addr); + let reserved = blk.load(I16, &reserved_ptr); + let integrity_bits = blk.and(I16, &reserved, "1031"); // 0x407 + let integrity_clean = blk.icmp_eq(I16, &integrity_bits, "0"); + // Repsel 4a.2: accept EITHER raw-f64 invariant — dense + // (GC_ARRAY_RAW_F64_LAYOUT, 0x80) or raw-f64-or-holes + // (GC_ARRAY_RAW_F64_HOLES, 0x1000). A canonical-numeric store + // preserves both invariants, and the extend arm below maintains + // the flag transition when it creates holes. This is what lets a + // `new Array(n)` mid-fill histogram write inline (the runtime + // set guard rejects holey arrays outright). + let dense_bits = blk.and(I16, &reserved, "4224"); // 0x1080 + let is_dense = blk.icmp_ne(I16, &dense_bits, "0"); + + let invalidated = blk.load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); + let default_prototype_chain = blk.icmp_eq(I8, &invalidated, "0"); + + let arr_ptr = blk.inttoptr(I64, &arr_handle); + let hdr_length = blk.load(I32, &arr_ptr); + let cap_addr = blk.add(I64, &arr_handle, "4"); + let cap_ptr = blk.inttoptr(I64, &cap_addr); + let hdr_capacity = blk.load(I32, &cap_ptr); + let index_nonnegative = blk.icmp_slt(I32, &idx_i32, "0"); + let index_nonnegative = blk.icmp_eq(I1, &index_nonnegative, "false"); + let length_sane = blk.icmp_ule(I32, &hdr_length, "16000000"); + let capacity_sane = blk.icmp_ule(I32, &hdr_capacity, "16000000"); + let length_within_capacity = blk.icmp_ule(I32, &hdr_length, &hdr_capacity); + + let mut guard_ok = blk.and(I1, &is_array, ¬_forwarded); + guard_ok = blk.and(I1, &guard_ok, &integrity_clean); + guard_ok = blk.and(I1, &guard_ok, &is_dense); + guard_ok = blk.and(I1, &guard_ok, &default_prototype_chain); + guard_ok = blk.and(I1, &guard_ok, &index_nonnegative); + guard_ok = blk.and(I1, &guard_ok, &length_sane); + guard_ok = blk.and(I1, &guard_ok, &capacity_sane); + guard_ok = blk.and(I1, &guard_ok, &length_within_capacity); + blk.cond_br(&guard_ok, &guarded_label, &cold_label); + } + } + if let Some(cold_idx) = cold_guard_idx { + ctx.current_block = cold_idx; + // Repsel 4a.2 (#6904): self-heal a stale growth-forwarded binding — + // follow the chain and write the live head back to the local slot + // (safe: this fast path is only taken for a plain stack local, and + // the fallback below already stores boxed heads into the same slot). + // This iteration still guards/falls back on the ORIGINAL value + // (chain-following keeps it correct); the NEXT iteration re-loads + // the repaired slot and takes the inline tier. + let blk = ctx.block(); + let fresh = blk.call(DOUBLE, "js_array_refresh_local_head", &[(DOUBLE, arr_box)]); + blk.store(DOUBLE, &fresh, &slot); + } let guard_ok = { let blk = ctx.block(); let guard_fn = if require_numeric_layout { @@ -221,11 +328,17 @@ pub(crate) fn lower_index_set_fast( let blk = ctx.block(); let (element_addr, element_ptr) = element_slot(blk, &arr_handle, &idx_i32); if require_numeric_layout { - let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, val_double); // GC_STORE_AUDIT(POINTER_FREE): require_numeric_layout proves the // array is raw-f64 and the value is canonicalized to a plain f64 — // no GC pointer is written into the slot, so no write barrier. - blk.store(DOUBLE, &numeric_value, &element_ptr); + if value_is_canonical_raw_f64 { + // Repsel 4a.0: the RHS is canonical by construction (literal / + // arithmetic / Math.* / coerce chain) — store verbatim. + blk.store(DOUBLE, val_double, &element_ptr); + } else { + let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, val_double); + blk.store(DOUBLE, &numeric_value, &element_ptr); + } } else { // In-place overwrite of a non-raw-layout (e.g. downgraded `any[]`) // array element: the slot holds a valid value, so the scalar-aware @@ -294,26 +407,129 @@ pub(crate) fn lower_index_set_fast( let cap_ptr = blk.inttoptr(I64, &cap_addr); blk.load(I32, &cap_ptr) }; + // Repsel 4a.2: the widened (hole-filling) extend arm is emitted only in + // non-feedback builds — feedback builds keep the previous shape (dense + // append inline, sparse extends via the recorded runtime arm) so their + // observation stream is unchanged. + let widened_numeric_extend = + require_numeric_layout && !super::typed_feedback_emission_enabled(); let can_extend_inline = { let blk = ctx.block(); let within_cap = blk.icmp_ult(I32, &idx_i32, &capacity); - let dense_append = blk.icmp_eq(I32, &idx_i32, &length); - blk.and(I1, &within_cap, &dense_append) + if widened_numeric_extend { + // Repsel 4a.2: widen the inline arm from `idx == length` (dense + // append) to any in-capacity extend. The gap `[length, idx)` is + // raw-TAG_HOLE-filled inline (pointer-free by construction — no + // per-slot GC notes or barriers needed under the raw-f64 layout + // proof), and the header flags transition dense→holes when a gap + // was actually created. Only `idx >= capacity` pays the runtime + // grow call. `check_cap` is only reached with `idx >= length` + // (the in-bounds branch tested `idx < length`; negative indices + // were rejected by both guard tiers), so `within_cap` alone + // decides. + within_cap + } else { + let dense_append = blk.icmp_eq(I32, &idx_i32, &length); + blk.and(I1, &within_cap, &dense_append) + } }; ctx.block() .cond_br(&can_extend_inline, &extend_inline_label, &realloc_label); ctx.current_block = extend_inline_idx; - { + if widened_numeric_extend { + // Hole-fill loop: for (j = length; j < idx; j++) slot[j] = TAG_HOLE. + // The counter lives in an entry-block alloca (a non-entry alloca + // inside a user loop would leak stack per iteration — #167 class); + // mem2reg rewrites it to a phi. + let fill_slot = ctx.func.alloca_entry(I32); + ctx.block().store(I32, &length, &fill_slot); + let fill_cond_idx = ctx.new_block("idxset.fill.cond"); + let fill_body_idx = ctx.new_block("idxset.fill.body"); + let fill_done_idx = ctx.new_block("idxset.fill.done"); + let fill_cond_label = ctx.block_label(fill_cond_idx); + let fill_body_label = ctx.block_label(fill_body_idx); + let fill_done_label = ctx.block_label(fill_done_idx); + ctx.block().br(&fill_cond_label); + + ctx.current_block = fill_cond_idx; + { + let blk = ctx.block(); + let j = blk.load(I32, &fill_slot); + let more = blk.icmp_ult(I32, &j, &idx_i32); + blk.cond_br(&more, &fill_body_label, &fill_done_label); + } + + ctx.current_block = fill_body_idx; + { + let blk = ctx.block(); + let j = blk.load(I32, &fill_slot); + let (_, hole_ptr) = element_slot(blk, &arr_handle, &j); + let hole_d = blk.bitcast_i64_to_double(crate::nanbox::TAG_HOLE_I64); + // GC_STORE_AUDIT(POINTER_FREE): TAG_HOLE sentinel under the + // raw-f64 layout proof — pointer-free, no note, no barrier. + blk.store(DOUBLE, &hole_d, &hole_ptr); + let j_next = blk.add(I32, &j, "1"); + blk.store(I32, &j_next, &fill_slot); + blk.br(&fill_cond_label); + } + + ctx.current_block = fill_done_idx; + { + let blk = ctx.block(); + let (_, element_ptr) = element_slot(blk, &arr_handle, &idx_i32); + // GC_STORE_AUDIT(POINTER_FREE): require_numeric_layout proves the + // array is raw-f64(-or-holes) and the value is canonical — no GC + // pointer is written, so no write barrier. + if value_is_canonical_raw_f64 { + blk.store(DOUBLE, val_double, &element_ptr); + } else { + let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, val_double); + blk.store(DOUBLE, &numeric_value, &element_ptr); + } + // Bump length: store idx+1 to arr_ptr+0. + let new_len = blk.add(I32, &idx_i32, "1"); + let len_ptr = blk.inttoptr(I64, &arr_handle); + blk.store(I32, &new_len, &len_ptr); + // Flag transition: holes were created iff idx > length. Then the + // DENSE bit (0x80) must drop and the HOLES bit (0x1000) records + // the still-valid raw-f64-or-holes invariant (branchless header + // rewrite; idempotent for already-holes-flagged arrays). This is + // feedback-stat-free by design: `invalidate_representation_change` + // only updates typed-feedback observation counters, and this tier + // is not emitted in feedback builds. + let created = blk.icmp_ugt(I32, &idx_i32, &length); + let reserved_addr = blk.sub(I64, &arr_handle, "6"); + let reserved_ptr = blk.inttoptr(I64, &reserved_addr); + let reserved = blk.load(I16, &reserved_ptr); + let without_dense = blk.and(I16, &reserved, "-129"); // ~0x80 + let with_holes = blk.or(I16, &without_dense, "4096"); // 0x1000 + let new_reserved = blk.select(I1, &created, I16, &with_holes, &reserved); + blk.store(I16, &new_reserved, &reserved_ptr); + blk.br(&merge_label); + } + } else if require_numeric_layout { + // Feedback-build numeric shape: dense append only (idx == length was + // proven by `can_extend_inline`), no holes are created. let blk = ctx.block(); - let (element_addr, element_ptr) = element_slot(blk, &arr_handle, &idx_i32); - if require_numeric_layout { + let (_, element_ptr) = element_slot(blk, &arr_handle, &idx_i32); + // GC_STORE_AUDIT(POINTER_FREE): require_numeric_layout proves the + // array is raw-f64 and the value is canonicalized to a plain f64 — + // no GC pointer is written into the slot, so no write barrier. + if value_is_canonical_raw_f64 { + blk.store(DOUBLE, val_double, &element_ptr); + } else { let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, val_double); - // GC_STORE_AUDIT(POINTER_FREE): require_numeric_layout proves the - // array is raw-f64 and the value is canonicalized to a plain f64 — - // no GC pointer is written into the slot, so no write barrier. blk.store(DOUBLE, &numeric_value, &element_ptr); - } else { + } + let new_len = blk.add(I32, &idx_i32, "1"); + let len_ptr = blk.inttoptr(I64, &arr_handle); + blk.store(I32, &new_len, &len_ptr); + blk.br(&merge_label); + } else { + let blk = ctx.block(); + let (element_addr, element_ptr) = element_slot(blk, &arr_handle, &idx_i32); + { let value_bits = emit_jsvalue_slot_store_on_block( blk, &element_ptr, diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 4ec238e44b..7a38ce614e 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -309,6 +309,23 @@ fn lower_class_method_bind( // counter), so the guard takes only `idx_i32` (no `f64` index) — keeping the // int→fp conversion out of the hot region. The boxed fallback still needs the // `f64` index, so it is materialized lazily inside the (cold) fallback block. +/// Repsel 4a.2 (#6904): the receiver's repairable local slot, when it is a +/// plain (non-boxed, non-captured) stack local. The guard tiers' COLD arm +/// stores the chain-followed live array head back into it so a stale +/// growth-forwarded binding (e.g. left behind by a specialized-ABI callee +/// growing a caller-allocated array) self-heals instead of pinning every +/// access to the boxed fallback. Boxed/captured locals are excluded — their +/// slot holds the box/capture pointer, not the array head. +fn receiver_repair_slot(ctx: &FnCtx<'_>, object: &Expr) -> Option { + let Expr::LocalGet(id) = object else { + return None; + }; + if ctx.boxed_vars.contains(id) || ctx.closure_captures.contains_key(id) { + return None; + } + ctx.locals.get(id).cloned() +} + fn lower_guarded_array_index_get( ctx: &mut FnCtx<'_>, arr_box: &str, @@ -316,6 +333,7 @@ fn lower_guarded_array_index_get( block_prefix: &str, require_numeric_layout: bool, coerce_numeric_fallback: bool, + receiver_slot: Option<&str>, ) -> Result { let contract = if require_numeric_layout { TypedFeedbackContract::numeric_array_get_index() @@ -335,14 +353,33 @@ fn lower_guarded_array_index_get( let fallback_label = ctx.block_label(fallback_idx); let merge_label = ctx.block_label(merge_idx); - if !require_numeric_layout && !typed_feedback_emission_enabled() { + if !typed_feedback_emission_enabled() { // Normal builds do not collect feedback. Inline the plain-array // structural guard instead of paying an out-of-line call merely to // rediscover the same header facts before the direct slot load below. // Prototype-chain invalidators are summarized by one sticky runtime // byte; per-array descriptors and forwarding remain receiver-local. + // + // Repsel 4a.1: the NUMERIC tier gets the same inline guard — plus an + // `_reserved & GC_ARRAY_RAW_F64_LAYOUT (0x80)` dense-proof test on the + // header word the plain guard already loads. A dense-flagged array + // needs no runtime call at all (the raw-f64 slot IS the value, no + // hole select). Arrays not yet flagged take a COLD out-of-line + // `js_typed_feedback_numeric_array_index_get_guard` call, whose + // first-touch path verifies-and-rewrites the layout (setting the + // flag), so the steady state is the inline tier. This ends the + // typed-`number[]`-slower-than-untyped inversion for reads. let deref_idx = ctx.new_block(&format!("{}.guard.deref", block_prefix)); let deref_label = ctx.block_label(deref_idx); + let cold_guard_idx = if require_numeric_layout { + Some(ctx.new_block(&format!("{}.guard.cold", block_prefix))) + } else { + None + }; + let guard_fail_label = match cold_guard_idx { + Some(idx) => ctx.block_label(idx), + None => fallback_label.clone(), + }; { let blk = ctx.block(); let arr_bits = blk.bitcast_double_to_i64(arr_box); @@ -351,7 +388,7 @@ fn lower_guarded_array_index_get( let is_pointer = blk.icmp_eq(I64, &tag, "32765"); // POINTER_TAG let above_handle_band = blk.icmp_ugt(I64, &arr_handle, "1048575"); let heap_candidate = blk.and(I1, &is_pointer, &above_handle_band); - blk.cond_br(&heap_candidate, &deref_label, &fallback_label); + blk.cond_br(&heap_candidate, &deref_label, &guard_fail_label); } ctx.current_block = deref_idx; @@ -399,7 +436,63 @@ fn lower_guarded_array_index_get( guard_ok = blk.and(I1, &guard_ok, &length_sane); guard_ok = blk.and(I1, &guard_ok, &capacity_sane); guard_ok = blk.and(I1, &guard_ok, &length_within_capacity); - blk.cond_br(&guard_ok, &fast_label, &fallback_label); + if require_numeric_layout { + // Dense raw-f64 proof: every slot in [0, length) holds + // canonical raw f64 bits (GC_ARRAY_RAW_F64_LAYOUT, 0x80). + // + // Repsel 4a.2 (#6904): a NUMBER-CONTEXT read (the caller will + // ToNumber the element regardless — `coerce_numeric_fallback`) + // additionally accepts the hole-tolerant invariant + // (GC_ARRAY_RAW_F64_HOLES, 0x1000): every slot is canonical + // raw f64 OR TAG_HOLE, and the fast arm canonicalizes any NaN + // payload (TAG_HOLE included) to the quiet NaN — bit-exact + // with ToNumber(undefined) for a hole and with ToNumber(NaN) + // for a stored NaN. This is the `new Array(n)` mid-fill axis: + // such arrays are provably-not-dense until the last slot is + // written, so the dense-only tier never fired for them. + let raw_mask = if coerce_numeric_fallback { + "4224" // 0x1080 = RAW_F64_LAYOUT | RAW_F64_HOLES + } else { + "128" // dense only: the raw slot is exposed verbatim + }; + let raw_bits = blk.and(I16, &reserved, raw_mask); + let is_raw = blk.icmp_ne(I16, &raw_bits, "0"); + guard_ok = blk.and(I1, &guard_ok, &is_raw); + } + blk.cond_br(&guard_ok, &fast_label, &guard_fail_label); + } + + if let Some(cold_idx) = cold_guard_idx { + // Cold arm: the out-of-line guard rebuilds unmarked-but-numeric + // arrays into raw-f64 layout (then this call site goes inline on + // every later read); everything else routes to the boxed fallback. + ctx.current_block = cold_idx; + // Self-heal a stale growth-forwarded binding first (see + // `receiver_repair_slot`): follow the chain, write the live head + // back to the local slot. This iteration still takes the guard + // on the ORIGINAL value (a forwarded head fails it → boxed + // fallback, which follows the chain — correct either way); every + // later iteration re-loads the repaired slot and goes inline. + if let Some(slot) = receiver_slot { + let blk = ctx.block(); + let fresh = blk.call(DOUBLE, "js_array_refresh_local_head", &[(DOUBLE, arr_box)]); + blk.store(DOUBLE, &fresh, slot); + } + let guard_ok = { + let blk = ctx.block(); + let guard_i32 = blk.call( + I32, + "js_typed_feedback_numeric_array_index_get_guard", + &[ + (I64, &feedback_site_id), + (DOUBLE, arr_box), + (I32, idx_i32), + (I32, "1"), + ], + ); + blk.icmp_ne(I32, &guard_i32, "0") + }; + ctx.block().cond_br(&guard_ok, &fast_label, &fallback_label); } } else { let guard_ok = { @@ -484,20 +577,34 @@ fn lower_guarded_array_index_get( let arr_bits = fast_blk.bitcast_double_to_i64(arr_box); let arr_handle = fast_blk.and(I64, &arr_bits, POINTER_MASK_I64); let fast_val = if require_numeric_layout { - // The `numeric_array_index_get_guard` on the way into this block already - // proved: a plain, non-forwarded `Array`, in raw-f64 numeric layout, - // with `index` in bounds (`plain_array_index_guard(.., in_bounds=true)` - // && `js_array_is_numeric_f64_layout`). So load the slot inline instead - // of calling `js_array_numeric_get_f64_unboxed`, whose hot path - // re-validates exactly those same conditions and then does this load. - // Raw-f64 arrays are dense (no HOLE slots) and the slot holds a raw f64, - // matching the runtime helper's `return *elements_ptr.add(index)`. + // The guard on the way into this block (inline tier or the runtime + // `numeric_array_index_get_guard`) already proved: a plain, + // non-forwarded `Array`, in raw-f64 (or, for number-context reads, + // raw-f64-or-holes) layout, with `index` in bounds. So load the slot + // inline instead of calling `js_array_numeric_get_f64_unboxed`, + // whose hot path re-validates exactly those same conditions and then + // does this load. let idx_i64 = fast_blk.zext(I32, idx_i32, I64); let byte_offset = fast_blk.shl(I64, &idx_i64, "3"); let with_header = fast_blk.add(I64, &byte_offset, "8"); let element_addr = fast_blk.add(I64, &arr_handle, &with_header); let element_ptr = fast_blk.inttoptr(I64, &element_addr); - fast_blk.load(DOUBLE, &element_ptr) + let raw = fast_blk.load(DOUBLE, &element_ptr); + if coerce_numeric_fallback { + // Repsel 4a.2: number-context canonicalization — any NaN payload + // (a TAG_HOLE slot under the raw-f64-or-holes proof, or a stored + // canonical NaN) becomes the quiet NaN. Bit-exact: + // ToNumber(undefined) = NaN for a hole, ToNumber(NaN) = NaN for a + // stored NaN, identity for every real number. PROOF-GATED: only + // sound because the guard admitted raw-f64-or-holes slots — an + // arbitrary NaN-boxed tag would be wrongly collapsed to NaN. + let is_ord = fast_blk.fcmp("ord", &raw, &raw); + fast_blk.select(I1, &is_ord, DOUBLE, &raw, "0x7FF8000000000000") + } else { + // Dense-only proof: no HOLE slots exist; the raw slot IS the + // element value, exposed verbatim. + raw + } } else { let idx_i64 = fast_blk.zext(I32, idx_i32, I64); let byte_offset = fast_blk.shl(I64, &idx_i64, "3"); @@ -1067,16 +1174,24 @@ pub(crate) fn lower_numeric_index_get_for_number_context( .any(|fact| fact.index_local_id == *idx_id && fact.array_local_id == *arr_id) { if let Some(i32_slot) = ctx.i32_counter_slots.get(idx_id).cloned() { + let repair_slot = receiver_repair_slot(ctx, object); let arr_box = lower_expr(ctx, object)?; let idx_i32 = ctx.block().load(I32, &i32_slot); return lower_guarded_array_index_get( - ctx, &arr_box, &idx_i32, "bidx.num", true, true, + ctx, + &arr_box, + &idx_i32, + "bidx.num", + true, + true, + repair_slot.as_deref(), ) .map(Some); } } } + let repair_slot = receiver_repair_slot(ctx, object); let arr_box = lower_expr(ctx, object)?; if !numeric_index_has_integer_array_index_proof(ctx, index) { let idx_double = lower_expr(ctx, index)?; @@ -1088,7 +1203,16 @@ pub(crate) fn lower_numeric_index_get_for_number_context( ))); } let idx_i32 = lower_expr_as_i32(ctx, index)?; - lower_guarded_array_index_get(ctx, &arr_box, &idx_i32, "arr", true, true).map(Some) + lower_guarded_array_index_get( + ctx, + &arr_box, + &idx_i32, + "arr", + true, + true, + repair_slot.as_deref(), + ) + .map(Some) } /// #5525: lower an `S[i]` read whose receiver is an *untyped* (`any`/unknown) @@ -1763,11 +1887,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { fact.index_local_id == *idx_id && fact.array_local_id == *arr_id }) { if let Some(i32_slot) = ctx.i32_counter_slots.get(idx_id).cloned() { + let repair_slot = receiver_repair_slot(ctx, object); let arr_box = lower_expr(ctx, object)?; let idx_i32 = ctx.block().load(I32, &i32_slot); if require_numeric_layout { return lower_guarded_array_index_get( - ctx, &arr_box, &idx_i32, "bidx.num", true, false, + ctx, + &arr_box, + &idx_i32, + "bidx.num", + true, + false, + repair_slot.as_deref(), ); } return lower_bounded_array_index_get(ctx, &arr_box, &idx_i32); @@ -1775,6 +1906,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } + let repair_slot = receiver_repair_slot(ctx, object); let arr_box = lower_expr(ctx, object)?; if !numeric_index_has_integer_array_index_proof(ctx, index) { let idx_double = lower_expr(ctx, index)?; @@ -1802,6 +1934,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "arr", require_numeric_layout, false, + repair_slot.as_deref(), ); } // Generic dynamic object access: stringify the index (no-op diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 30f5d49f7f..0dc06c38b9 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -1168,6 +1168,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } ctx.current_block = fast_idx; + let value_is_canonical_raw_f64 = + crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value); { let blk = ctx.block(); let arr_bits = blk.bitcast_double_to_i64(&arr_box); @@ -1183,12 +1185,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let with_header = blk.add(I64, &byte_offset, "8"); let element_addr = blk.add(I64, &arr_handle, &with_header); let element_ptr = blk.inttoptr(I64, &element_addr); - let numeric_value = - canonicalize_raw_f64_numeric_store_value(blk, &val_double); // GC_STORE_AUDIT(POINTER_FREE): guarded raw-f64 - // numeric store — the canonicalized value is a + // numeric store — the (canonical) value is a // plain f64, never a GC pointer, so no barrier. - blk.store(DOUBLE, &numeric_value, &element_ptr); + if value_is_canonical_raw_f64 { + // Repsel 4a.0: canonical by construction — + // skip js_array_numeric_value_to_raw_f64. + blk.store(DOUBLE, &val_double, &element_ptr); + } else { + let numeric_value = + canonicalize_raw_f64_numeric_store_value(blk, &val_double); + blk.store(DOUBLE, &numeric_value, &element_ptr); + } blk.br(&merge_label); } let stored = LoweredValue { @@ -1291,6 +1299,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // and the implicit length update vanishing. if let Some(id) = local_id { if ctx.locals.contains_key(&id) { + let value_is_canonical_raw_f64 = + crate::type_analysis::expr_produces_canonical_raw_f64(ctx, value); lower_index_set_fast( ctx, &arr_box, @@ -1301,6 +1311,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { write_barrier_needed, value_is_numeric, require_numeric_layout, + value_is_canonical_raw_f64, &feedback_site_id, )?; } else if let Some(global_name) = ctx.module_globals.get(&id).cloned() { diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 708d00e694..f7a48e485c 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -73,6 +73,10 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { module.declare_function("js_array_is_numeric_f64_layout", I32, &[I64]); module.declare_function("js_array_clear_numeric_layout", VOID, &[I64]); module.declare_function("js_array_numeric_value_to_raw_f64", DOUBLE, &[DOUBLE]); + // Repsel 4a.2 (#6904): cold-arm self-heal — follows the growth/GC + // forwarding chain of a POINTER-tagged array head and returns the + // re-boxed live head (identity for everything else). + module.declare_function("js_array_refresh_local_head", DOUBLE, &[DOUBLE]); module.declare_function("js_array_note_numeric_write", VOID, &[I64, I64]); module.declare_function("js_array_length", I32, &[I64]); // Array.isArray runtime dispatch for values with indeterminate diff --git a/test-files/test_gap_repsel_p4a_inline_tiers.ts b/test-files/test_gap_repsel_p4a_inline_tiers.ts new file mode 100644 index 0000000000..305d3f9df9 --- /dev/null +++ b/test-files/test_gap_repsel_p4a_inline_tiers.ts @@ -0,0 +1,94 @@ +// Test: repsel Phase 4a.1 — inline guard tiers for numeric plain-array reads, +// writes, and pushes. The typed-`number[]` tiers must behave byte-identically +// to the untyped/guarded paths across every edge the inline guards test: +// integrity flags (frozen/sealed), per-index descriptors, growth/forwarding, +// hole/undefined passthrough values, and NaN/-0/Infinity canonical stores. +// Validated byte-for-byte against `node --experimental-strip-types`. +export {}; + +// 1) canonical stores + dense append + sparse extend +const t: number[] = [1.5, 2.5, 3.5]; +t[1] = t[0] + 1; +console.log(t.join(",")); +t[3] = 9; // dense append (idx == length, within capacity) +console.log(t.join(","), t.length); +t[10] = 7; // sparse extend -> holes via the runtime arm +console.log(JSON.stringify(t), t.length, 5 in t); + +// 2) non-canonical RHS passthrough (runtime value check must keep working) +const src: number[] = new Array(3); +src[0] = 42; +const dst: number[] = [0, 0, 0]; +dst[0] = src[0]; // number passthrough +dst[1] = src[2]; // hole read -> undefined must be STORED as undefined +console.log(dst[0], dst[1], JSON.stringify(dst), 1 in dst); + +// 3) frozen / sealed arrays must never take the inline store. Post-state +// only: the strict-mode throw for the frozen write / sealed extend is a +// pre-existing gap in the boxed set fallback (present before this phase), +// so this test pins the data outcome, not the throw. +const fr: number[] = [1, 2]; +Object.freeze(fr); +try { + fr[0] = 5; +} catch (e) { + void e; +} +console.log(fr[0], fr.length); // 1 2 — untouched +const se: number[] = [1, 2]; +Object.seal(se); +se[0] = 9; // sealed in-bounds write is allowed +console.log(se[0]); +try { + se[2] = 5; +} catch (e) { + void e; +} +console.log(se.length, 2 in se); // 2 false — no extension + +// 4) per-index accessor diverts both reads and the fast tiers decline +const ac: number[] = [1, 2, 3]; +let got = 0; +Object.defineProperty(ac, 1, { + get() { + got++; + return 99; + }, +}); +console.log(ac[1], got, ac[0] + ac[1], got); + +// 5) growth + forwarding: push far past capacity, then read everything back +const g: number[] = []; +for (let i = 0; i < 1000; i++) g.push(i * 0.5); +let s = 0; +for (let i = 0; i < g.length; i++) s += g[i]; +console.log(g.length, s); + +// 6) aliased receivers: write through one name, read through the other +function bump(a: number[], b: number[]): number { + a[0] = a[0] + 1; + return b[0]; +} +const al: number[] = [10]; +console.log(bump(al, al), al[0]); + +// 7) NaN / -0 / Infinity through the canonical store tier +const w: number[] = [0]; +w[0] = 0 / 0; +console.log(Object.is(w[0], NaN)); +w[0] = -0; +console.log(Object.is(w[0], -0)); +w[0] = 1 / 0; +console.log(w[0]); +const big2: number[] = [1e308]; +big2[0] = big2[0] * 10; +console.log(big2[0]); + +// 8) push of non-canonical numeric values keeps the layout sound +const p: number[] = []; +for (let i = 0; i < 5; i++) p.push(src[0]); // read passthrough value +p.push(src[2] as unknown as number); // hole read -> pushes undefined +console.log(JSON.stringify(p), p.length, 5 in p); +let ps = 0; +for (let i = 0; i < p.length; i++) ps += p[i] || 0; +console.log(ps); From 17c488ebb36f6ff03ebb5251e7494f2879c0c1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 09:38:30 +0200 Subject: [PATCH 4/9] =?UTF-8?q?docs(rfc)+bench:=20repsel=204a=20=E2=80=94?= =?UTF-8?q?=20Array=20(Ptr)=20rows=20in=20RFC=20=C2=A74/?= =?UTF-8?q?=C2=A75.7;=20deterministic=20#6904=20histogram=20benchmark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RFC's type-class table and §5.7 typed-heap eligibility text were missing the plain-numeric-array class entirely; add the row (raw-f64 in-place storage, density lattice Dense ⊒ HolesOK ⊒ Boxed, hole observability contract) and the Phase 4a eligibility paragraph (provenance + containment + array-specific barrier list). bench_histogram_numarray.ts is the first value-indexed plain-array benchmark (counts[v] = (counts[v] || 0) + 1 over a data-dependent masked index): deterministic Park-Miller data (every intermediate below 2^53, engine-exact) and a printed checksum. --- benchmarks/bench_histogram_numarray.ts | 65 ++++++++++++++++++++++++++ docs/representation-selection-rfc.md | 16 +++++++ 2 files changed, 81 insertions(+) create mode 100644 benchmarks/bench_histogram_numarray.ts diff --git a/benchmarks/bench_histogram_numarray.ts b/benchmarks/bench_histogram_numarray.ts new file mode 100644 index 0000000000..75292d99c8 --- /dev/null +++ b/benchmarks/bench_histogram_numarray.ts @@ -0,0 +1,65 @@ +// Benchmark: value-indexed plain-array histogram (#6904, repsel Phase 4a) +// Tests: `counts[v] = (counts[v] || 0) + 1` — data-dependent (non-loop- +// counter) index, hole-defaulting read, numeric read-modify-write on a +// plain `number[]`. This is the shape where typed plain arrays were 26x +// slower than Node before Phase 4a (guarded out-of-line read + js_is_truthy +// + dynamic add + guarded out-of-line write per iteration). +// +// Deterministic: Park-Miller LCG (every intermediate < 2^53, so the value +// sequence is engine-exact) and a printed checksum. + +const BUCKETS = 4096; // power of two, mask-provable index +const N = 1_000_000; + +function fillData(): number[] { + const data: number[] = []; + let seed = 20260728; + for (let i = 0; i < N; i++) { + seed = (seed * 48271) % 2147483647; + data.push(seed); + } + return data; +} + +function histogram(data: number[]): number[] { + const counts: number[] = new Array(BUCKETS); + const mask = BUCKETS - 1; + for (let i = 0; i < data.length; i++) { + const v = data[i] & mask; + counts[v] = (counts[v] || 0) + 1; + } + return counts; +} + +function checksum(counts: number[]): number { + let acc = 0; + for (let i = 0; i < counts.length; i++) { + acc = (acc + (counts[i] || 0) * (i + 1)) % 1000000007; + } + return acc; +} + +const data = fillData(); + +const WARMUP_ITERATIONS = 3; +const TIMED_ITERATIONS = 20; + +let check = 0; +for (let i = 0; i < WARMUP_ITERATIONS; i++) { + check = checksum(histogram(data)); +} + +const start = Date.now(); +for (let i = 0; i < TIMED_ITERATIONS; i++) { + check = checksum(histogram(data)); +} +const end = Date.now(); + +const total = end - start; +const avg = total / TIMED_ITERATIONS; + +console.log("BENCHMARK:histogram_numarray"); +console.log("CHECKSUM:" + check); +console.log("TOTAL:" + total); +console.log("ITERATIONS:" + TIMED_ITERATIONS); +console.log("AVG:" + avg); diff --git a/docs/representation-selection-rfc.md b/docs/representation-selection-rfc.md index 09ac426e1d..e7d8783d70 100644 --- a/docs/representation-selection-rfc.md +++ b/docs/representation-selection-rfc.md @@ -70,6 +70,7 @@ roots — their win is *static dispatch and layout*, not root elimination. | `String` | `StringHeader*` | skip untag/retag; **direct** string-helper calls (no `js_jsvalue_to_string` dispatch) | rooted + rewritten | short-string (inline payload) values stay by-value | | `Object(shape S)` | `ObjHeader*` + static shape | **direct field offsets** (no hash lookup), **static method dispatch** | rooted + rewritten | the dominant win for real apps (property access ≫ arithmetic in web workloads); eligibility in §4.6 | | `TypedArray(kind)` | header ptr (+ hoisted data ptr/len in region) | guard-free element access once kind is proven | rooted + rewritten | data-ptr hoisting invalidated at safepoints if backing can move/detach | +| `Array` (`Ptr`) | `ArrayHeader*`; elements are raw f64 in place (the NaN-box of a number IS its double bits; `TAG_HOLE` marks holes) | guard-free element load/store + bare `.length` once density is proven; no per-store canonicalization/barrier/note | rooted + rewritten | density lattice `Dense ⊒ HolesOK ⊒ Boxed` (§5.7); a hole-OBSERVING read needs the `TAG_HOLE→undefined` select, hole-DEFAULT consumers (`\|\|0`, `??0`, `\|0`, `>>>0`, numeric `+`) admit the 2-instruction NaN-canonical form under the raw-f64-or-holes proof only; growth re-derives the base after any extend (Phase 4a) | | `Closure/Function` | code ptr + env ptr | static call targets (extends existing `FuncRef`) | env rooted | | | `SmallBigInt` | `i64` | native 64-bit arithmetic | not a root | overflow → boxed BigInt path, exists today | | `Null/Undefined` | singleton tags | fold checks statically | — | | @@ -203,6 +204,21 @@ Unboxed storage extends to heap slots where the *container's* shape is proven an fields need none (another structural win). - Typed-array element reads stop re-boxing when the consumer is typed (the element is raw in memory today; only the access path boxes it). +- **Plain numeric arrays (`Ptr`, Phase 4a):** a local `number[]` whose storage is + raw-f64 in place qualifies for guard-free element access under provenance + containment, + exactly like `Ptr` locals: single-`Let` provenance (`[]` / all-numeric literal / + `new Array(n)`(`.fill(num)`)), every use a numeric element read/write, `.length`, or numeric + push/pop, and the module-wide §5.2 barrier kill extended with the array-specific barriers + (indexed writes to `Array.prototype`/`Object.prototype`, `setPrototypeOf` on arrays, + `delete arr[i]`, `arr.length = n`, and the reordering mutators `sort`/`reverse`/ + `copyWithin`/`splice`/`shift`/`unshift` on the local). Eligibility carries a **density + lattice** `Dense ⊒ HolesOK ⊒ Boxed`: `Dense` (no hole can exist — literal provenance with + proven in-bounds/append-only writes) drops the hole select entirely; `HolesOK` keeps the + `TAG_HOLE` select for hole-observing reads while hole-default consumers (`||0`-class) use + the proof-gated 2-instruction canonical-NaN form; anything that can store a non-numeric + value demotes to `Boxed`. Hole-vs-undefined observability (`in`, `Object.keys`, + `JSON.stringify`) is preserved by keeping `TAG_HOLE` in storage and materializing + `undefined` only at the read edge. ## 6. Phasing (one design; each phase sound on its own) From f0f5d7cdc9497afc67ba51a9fb38760f0e3e98d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 09:39:25 +0200 Subject: [PATCH 5/9] style: cargo fmt over the 4a.0 files --- crates/perry-codegen/src/module.rs | 3 ++- crates/perry-codegen/src/type_analysis/numeric.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 83e51edf17..807bb70f46 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -810,7 +810,8 @@ mod tests { "declare i32 @js_typed_feedback_numeric_array_index_get_guard(i64, double, i32, i32) #4" )); assert_eq!( - ir.matches("attributes #4 = { nounwind willreturn }").count(), + ir.matches("attributes #4 = { nounwind willreturn }") + .count(), 1 ); // No setjmp declared → the setjmp-only groups stay out. diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index c61bf51fdb..de376cc3e0 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -398,7 +398,8 @@ pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool Expr::Update { .. } => is_numeric_expr(ctx, e), Expr::NumberCoerce(_) => true, Expr::Logical { left, right, .. } => { - expr_produces_canonical_raw_f64(ctx, left) && expr_produces_canonical_raw_f64(ctx, right) + expr_produces_canonical_raw_f64(ctx, left) + && expr_produces_canonical_raw_f64(ctx, right) } Expr::MathFloor(..) | Expr::MathCeil(..) From 815c6be7a543896cc1c86ed0e64ede4769302aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 09:41:34 +0200 Subject: [PATCH 6/9] test(runtime): use try_read_gc_header in the 4a.2 unit tests (addr-class ratchet) --- crates/perry-runtime/src/array/tests.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/perry-runtime/src/array/tests.rs b/crates/perry-runtime/src/array/tests.rs index ca4afff639..536df35a29 100644 --- a/crates/perry-runtime/src/array/tests.rs +++ b/crates/perry-runtime/src/array/tests.rs @@ -1525,9 +1525,9 @@ fn refresh_local_head_follows_growth_forwarding() { cur = js_array_push_f64(cur, i as f64); } // Growth happened: the original head is a forwarded stub. - let hdr = (stale as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + let hdr = crate::value::addr_class::try_read_gc_header(stale as usize).unwrap(); assert_ne!( - (*hdr).gc_flags & crate::gc::GC_FLAG_FORWARDED, + hdr.gc_flags & crate::gc::GC_FLAG_FORWARDED, 0, "expected the pre-grow head to be forwarded" ); @@ -1570,8 +1570,9 @@ fn sparse_extend_keeps_raw_f64_holes_invariant() { arr = js_array_push_f64(arr, 2.5); assert_eq!(js_array_is_numeric_f64_layout(arr), 1); arr = js_array_set_f64_extend(arr, 9, 7.5); - let hdr = (arr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; - let reserved = (*hdr)._reserved; + let reserved = crate::value::addr_class::try_read_gc_header(arr as usize) + .unwrap() + ._reserved; assert_eq!( reserved & crate::gc::GC_ARRAY_RAW_F64_LAYOUT, 0, @@ -1597,10 +1598,11 @@ fn sparse_extend_keeps_raw_f64_holes_invariant() { let s_box = f64::from_bits(crate::value::STRING_TAG | (s as u64 & crate::value::POINTER_MASK)); other = js_array_set_f64_extend(other, 6, s_box); - let ohdr = (other as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + let oreserved = crate::value::addr_class::try_read_gc_header(other as usize) + .unwrap() + ._reserved; assert_eq!( - (*ohdr)._reserved - & (crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES), + oreserved & (crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES), 0, "non-numeric store must clear both raw-f64 flags" ); @@ -1617,8 +1619,9 @@ fn push_built_array_gets_and_keeps_dense_raw_f64_flag() { arr = js_array_push_f64(arr, seed); } let probe = js_array_is_numeric_f64_layout(arr); - let header = (arr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; - let reserved = (*header)._reserved; + let reserved = crate::value::addr_class::try_read_gc_header(arr as usize) + .unwrap() + ._reserved; assert_eq!( probe, 1, "push-built numeric array should verify raw-f64 (reserved={reserved:#x})" From 20127711fb0bbf8f0cf9693927ea6522d4f50619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 09:45:56 +0200 Subject: [PATCH 7/9] changelog: 6915 fragment (repsel Phase 4a numeric plain-array tiers) --- changelog.d/6915-repsel-p4a-numarray-inline-tiers.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog.d/6915-repsel-p4a-numarray-inline-tiers.md diff --git a/changelog.d/6915-repsel-p4a-numarray-inline-tiers.md b/changelog.d/6915-repsel-p4a-numarray-inline-tiers.md new file mode 100644 index 0000000000..4c56ff9aa6 --- /dev/null +++ b/changelog.d/6915-repsel-p4a-numarray-inline-tiers.md @@ -0,0 +1,11 @@ +**Representation-selection Phase 4a — fast plain-array numeric elements (#6904)** + +Repairs the `number[]` access path in three layers (RFC `docs/representation-selection-rfc.md`, new §4/§5.7 `Array` rows): + +- **4a.0 inference**: `is_numeric_expr` gains the missing `Expr::Logical` arm (plus the matching boxed-fallback-hazard arm), and number-context `&&`/`||`/`??` lower with real-double operands — `(counts[v] || 0) + 1` now compiles to `fcmp one` + select + `fadd` instead of `js_is_truthy` + `js_dynamic_string_or_number_add`. `??` keeps its nullish test on the uncoerced value (`NaN ?? x` stays `NaN`). New LLVM attribute group `#4` (`nounwind willreturn`) for the audited array index/push guards. +- **4a.1 inline guard tiers**: the numeric read, write, and push paths get the inline structural guard the untyped tier had (header-byte tests, no out-of-line call on the fast path), ending the typed-`number[]`-slower-than-untyped inversion in both directions. Canonical-by-construction stores skip `js_array_numeric_value_to_raw_f64` entirely. +- **4a.2 holes axis**: number-context reads accept the raw-f64-or-holes invariant with a proof-gated 2-instruction NaN-canonicalization (bit-exact with `ToNumber(undefined)`/`ToNumber(NaN)`); the write tier gap-fills sparse extends inline with a dense→holes header transition; and `js_array_set_f64_extend` no longer permanently demotes sparsely-extended numeric arrays (its own `TAG_HOLE` gap stores previously cleared the layout flags). Hole-vs-undefined observability (`in`/`Object.keys`/`JSON.stringify`) is byte-exact throughout. + +Also fixes a latent Phase 2 interaction: a specialized-ABI callee growing a caller-allocated array left the caller's binding on a pre-growth forwarded stub, pinning every access (including the pre-existing packed-loop guards) to the boxed chain-following fallback. The guard tiers' cold arms now self-heal the binding via `js_array_refresh_local_head`. + +Deterministic #6904 histogram benchmark added (`benchmarks/bench_histogram_numarray.ts`); three new gap tests + runtime unit tests. The 4a.3 `Ptr` collector (guard-free consumers) is documented in the RFC and follows separately. From 20b7f246ba556e96e696aa8383f35b6bd6471c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 10:51:36 +0200 Subject: [PATCH 8/9] test(compiler-output): re-pin the numeric_arrays structural proof to the repsel 4a shapes The native-region-proof gate asserted the PRE-4a tier shapes: guarded numeric push helper present (js_array_numeric_push_f64_unboxed + its five NumericArrayPush record requirements), read fast arm = bare load, write fast arm = js_array_numeric_value_to_raw_f64 + store. Phase 4a deliberately supersedes all three: canonical pushes take the inline store + length bump, the read fast arm canonicalizes NaN payloads (fcmp ord + select, robust to instcombine's ord x,x -> ord x,0.0), and canonical RHS writes store verbatim. Update the ir_checks to pin the NEW shapes (with regex_none forbidding the old helper calls on these paths), drop the obsolete push record requirements (the inline tier is record-free by design; the get/set record requirements remain), and update the harness unit-test fixtures to match. Verified locally: numeric_arrays workload gate passes, full native-region-proof and native-abi-proof suites pass, and both python unittest modules pass. --- benchmarks/compiler_output/workloads.toml | 88 +++++---------- tests/test_compiler_output_regression.py | 125 +++++++--------------- 2 files changed, 68 insertions(+), 145 deletions(-) diff --git a/benchmarks/compiler_output/workloads.toml b/benchmarks/compiler_output/workloads.toml index dffa8886e2..8410163efa 100644 --- a/benchmarks/compiler_output/workloads.toml +++ b/benchmarks/compiler_output/workloads.toml @@ -625,24 +625,39 @@ write_barriers_traced = 16 boxed_number_allocations_static = 0 buffer_slow_path_accesses_static = 0 +# Repsel 4a.1 (#6904): a canonical-numeric push lowers to the inline store + +# length bump; the guarded raw-f64 helper tier (js_typed_feedback_numeric_ +# array_push_guard + js_array_numeric_push_f64_unboxed) is gone from this +# shape. js_array_push_f64 legitimately remains in the forwarded/realloc arms. [[workloads.numeric_arrays.ir_checks]] -name = "numeric_array_uses_unboxed_push" -contains = "js_array_numeric_push_f64_unboxed" -detail = "numeric Array.push uses the guarded raw-f64 helper" +name = "numeric_array_push_inlines_store" +regex = '''apush\.inbounds\.\d+:[\s\S]*?store double [^\n]+\n[\s\S]*?store i32 %\w+, ptr %\w+[^\n]*\n[\s\S]*?br label %apush\.merge''' +regex_none = [ + "call i64 @js_array_numeric_push_f64_unboxed", + "call i32 @js_typed_feedback_numeric_array_push_guard", +] +detail = "canonical numeric Array.push takes the inline store + length bump (repsel 4a.1; guarded helper tier elided)" +# Repsel 4a.1/4a.2: the numeric read has an inline guard tier; the fast arm +# loads the raw slot and canonicalizes any NaN payload to the quiet NaN +# (proof-gated under raw-f64(-or-holes); bit-exact with ToNumber semantics). +# The out-of-line guard remains as the cold arm. [[workloads.numeric_arrays.ir_checks]] name = "numeric_array_uses_unboxed_get" contains = "js_typed_feedback_numeric_array_index_get_guard" -regex = '''bidx\.num\.fast\.\d+:[\s\S]*?inttoptr i64 %\w+ to ptr\s*\n\s*%\w+ = load double, ptr %\w+[^\n]*\n\s*br label %bidx\.num\.merge''' +regex = '''bidx\.num\.fast\.\d+:[\s\S]*?inttoptr i64 %\w+ to ptr\s*\n\s*%\w+ = load double, ptr %\w+[^\n]*\n\s*%\w+ = fcmp ord double %\w+, (?:%\w+|0\.000000e\+00)\s*\n\s*%\w+ = select i1 %\w+, double %\w+, double 0x7FF8000000000000\s*\n\s*br label %bidx\.num\.merge''' regex_none = ["call double @js_array_numeric_get_f64_unboxed"] -detail = "numeric indexed read takes the guarded raw-f64 fast path and loads the slot inline (inttoptr + load double in bidx.num.fast; helper call elided)" +detail = "numeric indexed read takes the inline raw-f64 fast arm (load + proof-gated NaN canonicalization; helper call elided)" +# Repsel 4a.1: a canonical-raw-f64 RHS stores verbatim — no +# js_array_numeric_value_to_raw_f64 call on the write fast arm. The +# out-of-line set guard remains as the cold arm. [[workloads.numeric_arrays.ir_checks]] name = "numeric_array_uses_unboxed_set" contains = "js_typed_feedback_numeric_array_index_set_guard" -regex = '''idxset\.(?:bounded_numeric_fast|inbounds)\.\d+:[\s\S]*?inttoptr i64 %\w+ to ptr[\s\S]*?call double @js_array_numeric_value_to_raw_f64\(double %\w+\)\s*\n\s*store double %\w+, ptr %\w+[^\n]*\n\s*br label %idxset\.(?:bounded_numeric_merge|merge)''' +regex = '''idxset\.(?:bounded_numeric_fast|inbounds)\.\d+:[\s\S]*?inttoptr i64 %\w+ to ptr\s*\n\s*store double %\w+, ptr %\w+[^\n]*\n\s*br label %idxset\.(?:bounded_numeric_merge|merge)''' regex_none = ["call i32 @js_array_numeric_set_f64_unboxed"] -detail = "numeric indexed write takes the guarded raw-f64 fast path, canonicalizes the value, and stores the raw slot inline" +detail = "numeric indexed write takes the inline raw-f64 fast arm and stores the canonical value verbatim (canonicalization call elided for canonical RHS)" [[workloads.numeric_arrays.stdout_checks]] name = "numeric_arrays_checksum" @@ -652,58 +667,13 @@ detail = "numeric-array fixture stdout checksum" [workloads.numeric_arrays.native_rep_checks] allow_materialization_reasons = ["runtime_api"] -[[workloads.numeric_arrays.native_rep_checks.require_records]] -name = "numeric_array_push_fast_f64" -expr_kind = "NumericArrayPush" -consumer = "js_array_numeric_push_f64_unboxed" -native_rep_name = "f64" -access_mode = "checked_native" -bounds_state = "proven_or_guarded" -consumed_fact_kind = "raw_f64_layout" -consumed_fact_state = "consumed" - -[[workloads.numeric_arrays.native_rep_checks.require_records]] -name = "numeric_array_push_guard_consumed" -expr_kind = "NumericArrayPush" -consumer = "js_array_numeric_push_f64_unboxed" -native_rep_name = "f64" -access_mode = "checked_native" -bounds_state = "proven_or_guarded" -consumed_fact_kind = "bounds" -consumed_fact_state = "consumed" - -[[workloads.numeric_arrays.native_rep_checks.require_records]] -name = "numeric_array_push_dynamic_fallback" -expr_kind = "NumericArrayPush" -consumer = "js_array_push_f64" -access_mode = "dynamic_fallback" -materialization_reason = "runtime_api" -fallback_reason = "runtime_api" -rejected_fact_kind = "raw_f64_layout" -rejected_fact_state = "rejected" -rejected_fact_reason = "runtime_api" - -[[workloads.numeric_arrays.native_rep_checks.require_records]] -name = "numeric_array_push_dynamic_fallback_invalidates_layout" -expr_kind = "NumericArrayPush" -consumer = "js_array_push_f64" -access_mode = "dynamic_fallback" -materialization_reason = "runtime_api" -fallback_reason = "runtime_api" -rejected_fact_kind = "raw_f64_layout" -rejected_fact_state = "invalidated" -rejected_fact_reason = "runtime_api" - -[[workloads.numeric_arrays.native_rep_checks.require_records]] -name = "numeric_array_push_materialization_hazard_invalidated" -expr_kind = "NumericArrayPush" -consumer = "js_array_push_f64" -access_mode = "dynamic_fallback" -materialization_reason = "runtime_api" -fallback_reason = "runtime_api" -rejected_fact_kind = "materialization_hazard" -rejected_fact_state = "invalidated" -rejected_fact_reason = "runtime_api" +# Repsel 4a.1 (#6904): the five NumericArrayPush record requirements that +# pinned the guarded helper tier (js_array_numeric_push_f64_unboxed fast/ +# guard-consumed + the js_array_push_f64 dynamic-fallback trio) are gone — +# a canonical-numeric push now lowers through the record-free inline store +# tier (asserted structurally by numeric_array_push_inlines_store above). +# Non-canonical numeric pushes still take the recorded guarded tier; this +# fixture's pushes are literal (canonical) by design. [[workloads.numeric_arrays.native_rep_checks.require_records]] name = "numeric_array_get_fast_f64" diff --git a/tests/test_compiler_output_regression.py b/tests/test_compiler_output_regression.py index c134d78053..01ea825152 100644 --- a/tests/test_compiler_output_regression.py +++ b/tests/test_compiler_output_regression.py @@ -473,10 +473,26 @@ def numeric_array_native_records(): def numeric_arrays_inline_ir(): + # Repsel 4a.1/4a.2 shapes: canonical pushes take the inline store + length + # bump (no guarded helper), the numeric read fast arm loads the raw slot + # and canonicalizes NaN payloads (fcmp ord + select), and a canonical RHS + # write stores verbatim (no js_array_numeric_value_to_raw_f64 call). return """ define i32 @main() { entry: - call i64 @js_array_numeric_push_f64_unboxed(i64 1, double 2.0) + br label %apush.inbounds.0 + +apush.inbounds.0: + %lp = inttoptr i64 1 to ptr + %len = load i32, ptr %lp + %paddr = add i64 1, 8 + %pp = inttoptr i64 %paddr to ptr + store double 2.0, ptr %pp, align 8 + %newlen = add i32 %len, 1 + store i32 %newlen, ptr %lp, align 4 + br label %apush.merge.0 + +apush.merge.0: %g = call i32 @js_typed_feedback_numeric_array_index_get_guard(i64 1, double 0.0, double 0.0, i32 0, i32 1) %gc = icmp ne i32 %g, 0 br i1 %gc, label %bidx.num.fast.1, label %bidx.num.fallback.2 @@ -485,6 +501,8 @@ def numeric_arrays_inline_ir(): %addr = add i64 1, 8 %p = inttoptr i64 %addr to ptr %v = load double, ptr %p, align 8 + %vo = fcmp ord double %v, %v + %vc = select i1 %vo, double %v, double 0x7FF8000000000000 br label %bidx.num.merge.3 bidx.num.fallback.2: @@ -499,8 +517,7 @@ def numeric_arrays_inline_ir(): %sval = fadd double 3.0, 0.0 %saddr = add i64 1, 8 %sp = inttoptr i64 %saddr to ptr - %sraw = call double @js_array_numeric_value_to_raw_f64(double %sval) - store double %sraw, ptr %sp, align 8 + store double %sval, ptr %sp, align 8 br label %idxset.bounded_numeric_merge.5 idxset.bounded_numeric_merge.5: @@ -1678,44 +1695,12 @@ def test_native_rep_unchecked_unknown_bounds_fails_gate(self): def test_generic_native_rep_checks_require_configured_records(self): # The numeric indexed read is inlined: a guarded fast block computes the - # element pointer (inttoptr) and performs a direct `load double` instead - # of calling js_array_numeric_get_f64_unboxed. The indexed write - # canonicalizes the input and stores inline after its guard instead of - # calling the raw-f64 set helper. - ir = """ -define i32 @main() { -entry: - call i64 @js_array_numeric_push_f64_unboxed(i64 1, double 2.0) - %g = call i32 @js_typed_feedback_numeric_array_index_get_guard(i64 1, double 0.0, double 0.0, i32 0, i32 1) - %gc = icmp ne i32 %g, 0 - br i1 %gc, label %bidx.num.fast.1, label %bidx.num.fallback.2 - -bidx.num.fast.1: - %addr = add i64 1, 8 - %p = inttoptr i64 %addr to ptr - %v = load double, ptr %p, align 8 - br label %bidx.num.merge.3 - -bidx.num.fallback.2: - br label %bidx.num.merge.3 - -bidx.num.merge.3: - %sg = call i32 @js_typed_feedback_numeric_array_index_set_guard(i64 1, double 0.0, i32 0, double 3.0, i32 1) - %sc = icmp ne i32 %sg, 0 - br i1 %sc, label %idxset.bounded_numeric_fast.4, label %idxset.bounded_numeric_merge.5 - -idxset.bounded_numeric_fast.4: - %sval = fadd double 3.0, 0.0 - %saddr = add i64 1, 8 - %sp = inttoptr i64 %saddr to ptr - %sraw = call double @js_array_numeric_value_to_raw_f64(double %sval) - store double %sraw, ptr %sp, align 8 - br label %idxset.bounded_numeric_merge.5 - -idxset.bounded_numeric_merge.5: - ret i32 0 -} -""" + # element pointer (inttoptr), performs a direct `load double`, and + # canonicalizes NaN payloads (repsel 4a.2) instead of calling + # js_array_numeric_get_f64_unboxed. The indexed write stores the + # canonical value inline after its guard, and canonical pushes take the + # inline store + length bump (repsel 4a.1). + ir = numeric_arrays_inline_ir() records = numeric_array_native_records() report = HARNESS.verify_artifacts( workload="numeric_arrays", @@ -1775,27 +1760,7 @@ def test_stdout_checks_are_exact_for_every_run(self): ) def test_numeric_array_native_rep_checks_require_raw_layout_facts(self): - ir = """ -define i32 @main() { -entry: - call i64 @js_array_numeric_push_f64_unboxed(i64 1, double 2.0) - call double @js_array_numeric_get_f64_unboxed(i64 1, i32 0) - %sg = call i32 @js_typed_feedback_numeric_array_index_set_guard(i64 1, double 0.0, i32 0, double 3.0, i32 1) - %sc = icmp ne i32 %sg, 0 - br i1 %sc, label %idxset.bounded_numeric_fast.4, label %idxset.bounded_numeric_merge.5 - -idxset.bounded_numeric_fast.4: - %sval = fadd double 3.0, 0.0 - %saddr = add i64 1, 8 - %sp = inttoptr i64 %saddr to ptr - %sraw = call double @js_array_numeric_value_to_raw_f64(double %sval) - store double %sraw, ptr %sp, align 8 - br label %idxset.bounded_numeric_merge.5 - -idxset.bounded_numeric_merge.5: - ret i32 0 -} -""" + ir = numeric_arrays_inline_ir() checked_records = numeric_array_native_records() for record in checked_records: if record.get("access_mode") == "checked_native": @@ -1811,7 +1776,10 @@ def test_numeric_array_native_rep_checks_require_raw_layout_facts(self): ) self.assertEqual(checked_report["status"], "fail") self.assertTrue( - any("native_reps_required_numeric_array_push_fast_f64" in error for error in checked_report["errors"]), + any( + "native_reps_required_numeric_array_get_fast_f64" in error + for error in checked_report["errors"] + ), checked_report["errors"], ) @@ -1883,30 +1851,15 @@ def test_numeric_array_native_rep_checks_require_fallback_reason(self): ) def test_numeric_array_native_rep_checks_require_fact_reason(self): - ir = """ -define i32 @main() { -entry: - call i64 @js_array_numeric_push_f64_unboxed(i64 1, double 2.0) - call double @js_array_numeric_get_f64_unboxed(i64 1, i32 0) - %sg = call i32 @js_typed_feedback_numeric_array_index_set_guard(i64 1, double 0.0, i32 0, double 3.0, i32 1) - %sc = icmp ne i32 %sg, 0 - br i1 %sc, label %idxset.bounded_numeric_fast.4, label %idxset.bounded_numeric_merge.5 - -idxset.bounded_numeric_fast.4: - %sval = fadd double 3.0, 0.0 - %saddr = add i64 1, 8 - %sp = inttoptr i64 %saddr to ptr - %sraw = call double @js_array_numeric_value_to_raw_f64(double %sval) - store double %sraw, ptr %sp, align 8 - br label %idxset.bounded_numeric_merge.5 - -idxset.bounded_numeric_merge.5: - ret i32 0 -} -""" + # Repsel 4a.1: the guarded push tier (and its record requirements) is + # gone, so the reason-required probe targets the GET fallback record. + ir = numeric_arrays_inline_ir() records = numeric_array_native_records() for record in records: - if record.get("access_mode") == "dynamic_fallback": + if ( + record.get("access_mode") == "dynamic_fallback" + and record.get("consumer") == "js_typed_feedback_array_index_get_fallback_boxed" + ): for fact in record.get("rejected_facts", []): if fact.get("kind") == "raw_f64_layout": fact["reason"] = None @@ -1923,7 +1876,7 @@ def test_numeric_array_native_rep_checks_require_fact_reason(self): self.assertEqual(report["status"], "fail") self.assertTrue( any( - "native_reps_required_numeric_array_push_dynamic_fallback" in error + "native_reps_required_numeric_array_get_dynamic_fallback" in error for error in report["errors"] ), report["errors"], From c950b46ba8232ff494a79cd8bcfb8ab6e66aff37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 11:11:45 +0200 Subject: [PATCH 9/9] review: RFC shipped-vs-deferred accuracy, shared residual-coerce helper, callee-growth gap test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit findings on #6915: - docs/representation-selection-rfc.md: the Array row and the §5.7 paragraph now separate what SHIPPED in 4a.0-4a.2 (inline guarded tiers, zero calls on the fast path) from the DEFERRED 4a.3 collector contract (fully guard-free access under provenance + containment) — the deferred design must not read as implemented. - expr/binary.rs: extract operand_needs_residual_coerce so the number- context logical lowering and the binary arithmetic path share one residual-coercion rule instead of duplicating it. - test_gap_repsel_p4a_inline_tiers.ts: add the caller-owned-array callee-growth cases (both the passed-in and the callee-allocated specialized-ABI shapes): growth past capacity installs forwarding stubs, and the caller's binding must keep observing full contents, length, and stay writable/pushable after return. Byte-exact vs node, also under PERRY_GC_FORCE_EVACUATE=1, PERRY_GEN_GC=0, and PERRY_GEN_GC_EVACUATE=0. --- crates/perry-codegen/src/expr/binary.rs | 29 +++++++------ docs/representation-selection-rfc.md | 21 ++++++---- .../test_gap_repsel_p4a_inline_tiers.ts | 41 ++++++++++++++++++- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 795410ab1d..fc96b1a2dc 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -73,16 +73,23 @@ fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, Ok((lower_expr(ctx, expr)?, false)) } +/// The shared residual-coercion rule for arithmetic operands: a lowered +/// operand still needs a `js_number_coerce` when the fallback did not already +/// coerce it AND it is either not statically numeric (booleans, `null`, …) +/// or can surface a boxed value through a raw-f64 read's cold fallback. +fn operand_needs_residual_coerce(ctx: &FnCtx<'_>, expr: &Expr, fallback_coerced: bool) -> bool { + !fallback_coerced + && (!crate::type_analysis::is_numeric_expr(ctx, expr) + || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr)) +} + /// Lower an operand in number context: route through -/// [`lower_arithmetic_operand`], then apply the same residual-coercion rule -/// the binary arithmetic path uses — the result is ALWAYS a real (canonical) -/// numeric double, never a NaN-boxed value. +/// [`lower_arithmetic_operand`], then apply the shared residual-coercion rule +/// — the result is ALWAYS a real (canonical) numeric double, never a +/// NaN-boxed value. fn lower_operand_as_number(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let (raw, fallback_coerced) = lower_arithmetic_operand(ctx, expr)?; - let numeric = crate::type_analysis::is_numeric_expr(ctx, expr); - let needs_coerce = !fallback_coerced - && (!numeric || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr)); - if needs_coerce { + if operand_needs_residual_coerce(ctx, expr, fallback_coerced) { Ok(ctx .block() .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &raw)])) @@ -589,12 +596,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // JS: `true + true = 2`, `null + 1 = 1`, etc. Without // this, fadd on NaN-tagged booleans propagates the NaN // payload instead of computing 1.0 + 1.0 = 2.0. - let l_numeric = is_numeric_expr(ctx, left); - let r_numeric = is_numeric_expr(ctx, right); - let l_needs_coerce = !l_fallback_coerced - && (!l_numeric || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, left)); - let r_needs_coerce = !r_fallback_coerced - && (!r_numeric || expr_may_return_boxed_value_from_raw_f64_fallback(ctx, right)); + let l_needs_coerce = operand_needs_residual_coerce(ctx, left, l_fallback_coerced); + let r_needs_coerce = operand_needs_residual_coerce(ctx, right, r_fallback_coerced); let l = if l_needs_coerce { ctx.block() .call(DOUBLE, "js_number_coerce", &[(DOUBLE, &l_raw)]) diff --git a/docs/representation-selection-rfc.md b/docs/representation-selection-rfc.md index e7d8783d70..a51ca4787b 100644 --- a/docs/representation-selection-rfc.md +++ b/docs/representation-selection-rfc.md @@ -70,7 +70,7 @@ roots — their win is *static dispatch and layout*, not root elimination. | `String` | `StringHeader*` | skip untag/retag; **direct** string-helper calls (no `js_jsvalue_to_string` dispatch) | rooted + rewritten | short-string (inline payload) values stay by-value | | `Object(shape S)` | `ObjHeader*` + static shape | **direct field offsets** (no hash lookup), **static method dispatch** | rooted + rewritten | the dominant win for real apps (property access ≫ arithmetic in web workloads); eligibility in §4.6 | | `TypedArray(kind)` | header ptr (+ hoisted data ptr/len in region) | guard-free element access once kind is proven | rooted + rewritten | data-ptr hoisting invalidated at safepoints if backing can move/detach | -| `Array` (`Ptr`) | `ArrayHeader*`; elements are raw f64 in place (the NaN-box of a number IS its double bits; `TAG_HOLE` marks holes) | guard-free element load/store + bare `.length` once density is proven; no per-store canonicalization/barrier/note | rooted + rewritten | density lattice `Dense ⊒ HolesOK ⊒ Boxed` (§5.7); a hole-OBSERVING read needs the `TAG_HOLE→undefined` select, hole-DEFAULT consumers (`\|\|0`, `??0`, `\|0`, `>>>0`, numeric `+`) admit the 2-instruction NaN-canonical form under the raw-f64-or-holes proof only; growth re-derives the base after any extend (Phase 4a) | +| `Array` (`Ptr`) | `ArrayHeader*`; elements are raw f64 in place (the NaN-box of a number IS its double bits; `TAG_HOLE` marks holes) | SHIPPED (Phase 4a.0-4a.2): inline guarded tiers — header-proof tests instead of out-of-line guard calls, zero runtime calls on the fast path. DEFERRED (Phase 4a.3): guard-free element load/store + bare `.length` under a collector proof; no per-access header tests at all | rooted + rewritten | density lattice `Dense ⊒ HolesOK ⊒ Boxed` (§5.7); a hole-OBSERVING read needs the `TAG_HOLE→undefined` select, hole-DEFAULT consumers (`\|\|0`, `??0`, `\|0`, `>>>0`, numeric `+`) admit the 2-instruction NaN-canonical form under the raw-f64-or-holes proof only; growth re-derives the base after any extend | | `Closure/Function` | code ptr + env ptr | static call targets (extends existing `FuncRef`) | env rooted | | | `SmallBigInt` | `i64` | native 64-bit arithmetic | not a root | overflow → boxed BigInt path, exists today | | `Null/Undefined` | singleton tags | fold checks statically | — | | @@ -204,9 +204,13 @@ Unboxed storage extends to heap slots where the *container's* shape is proven an fields need none (another structural win). - Typed-array element reads stop re-boxing when the consumer is typed (the element is raw in memory today; only the access path boxes it). -- **Plain numeric arrays (`Ptr`, Phase 4a):** a local `number[]` whose storage is - raw-f64 in place qualifies for guard-free element access under provenance + containment, - exactly like `Ptr` locals: single-`Let` provenance (`[]` / all-numeric literal / +- **Plain numeric arrays (`Ptr`, Phase 4a).** SHIPPED in Phase 4a.0-4a.2: the + access path uses inline guarded tiers (per-access header-proof tests on the raw-f64 / + raw-f64-or-holes bits instead of out-of-line guard calls; zero runtime calls on the fast + path). DEFERRED to Phase 4a.3 — the following collector contract is a design, NOT yet + implemented: a local `number[]` whose storage is raw-f64 in place would qualify for + fully guard-free element access under provenance + containment, exactly like + `Ptr` locals: single-`Let` provenance (`[]` / all-numeric literal / `new Array(n)`(`.fill(num)`)), every use a numeric element read/write, `.length`, or numeric push/pop, and the module-wide §5.2 barrier kill extended with the array-specific barriers (indexed writes to `Array.prototype`/`Object.prototype`, `setPrototypeOf` on arrays, @@ -215,10 +219,11 @@ Unboxed storage extends to heap slots where the *container's* shape is proven an lattice** `Dense ⊒ HolesOK ⊒ Boxed`: `Dense` (no hole can exist — literal provenance with proven in-bounds/append-only writes) drops the hole select entirely; `HolesOK` keeps the `TAG_HOLE` select for hole-observing reads while hole-default consumers (`||0`-class) use - the proof-gated 2-instruction canonical-NaN form; anything that can store a non-numeric - value demotes to `Boxed`. Hole-vs-undefined observability (`in`, `Object.keys`, - `JSON.stringify`) is preserved by keeping `TAG_HOLE` in storage and materializing - `undefined` only at the read edge. + the proof-gated 2-instruction canonical-NaN form (this consumer form DID ship in 4a.2, + inside the guarded tiers); anything that can store a non-numeric value demotes to + `Boxed`. Hole-vs-undefined observability (`in`, `Object.keys`, `JSON.stringify`) is + preserved by keeping `TAG_HOLE` in storage and materializing `undefined` only at the + read edge. ## 6. Phasing (one design; each phase sound on its own) diff --git a/test-files/test_gap_repsel_p4a_inline_tiers.ts b/test-files/test_gap_repsel_p4a_inline_tiers.ts index 305d3f9df9..89e551f3bd 100644 --- a/test-files/test_gap_repsel_p4a_inline_tiers.ts +++ b/test-files/test_gap_repsel_p4a_inline_tiers.ts @@ -84,7 +84,46 @@ const big2: number[] = [1e308]; big2[0] = big2[0] * 10; console.log(big2[0]); -// 8) push of non-canonical numeric values keeps the layout sound +// 8) caller-owned arrays grown by a callee past capacity (the +// forwarding-pointer path): every growth installs a forwarding stub at the +// old head, and a specialized-ABI callee's write-backs only update its own +// param slot — the caller's binding must still observe the full contents, +// length, and stay writable/pushable after return (the guard tiers' +// self-heal repairs the binding; the boxed fallback follows the chain). +function growInto(a: number[], n: number): void { + for (let i = 0; i < n; i++) { + a.push(i * 0.25); + } +} +const owned: number[] = [1.5]; +growInto(owned, 200); // initial capacity is tiny -> several growths +console.log(owned.length, owned[0], owned[1], owned[200], owned[150]); +let gsum = 0; +for (let i = 0; i < owned.length; i++) gsum += owned[i] || 0; +console.log(gsum); +owned[3] = owned[3] + 1; // caller write after callee growth +owned.push(999); // caller push after callee growth +console.log(owned.length, owned[3], owned[201]); +console.log(JSON.stringify(owned.slice(0, 4))); + +// same shape, but the callee ALLOCATES and returns (the specialized-ABI +// caller-allocated variant observed in the wild) +function makeSeries(n: number): number[] { + const out: number[] = []; + for (let i = 0; i < n; i++) { + out.push(i * 0.5); + } + return out; +} +const series = makeSeries(300); +console.log(series.length, series[0], series[299], series[123]); +series[5] = series[5] * 2; +series.push(-1); +let ssum = 0; +for (let i = 0; i < series.length; i++) ssum += series[i] || 0; +console.log(series.length, series[5], ssum); + +// 8b) push of non-canonical numeric values keeps the layout sound const p: number[] = []; for (let i = 0; i < 5; i++) p.push(src[0]); // read passthrough value p.push(src[2] as unknown as number); // hole read -> pushes undefined