Skip to content
Merged
65 changes: 65 additions & 0 deletions benchmarks/bench_histogram_numarray.ts
Original file line number Diff line number Diff line change
@@ -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);
88 changes: 29 additions & 59 deletions benchmarks/compiler_output/workloads.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions changelog.d/6915-repsel-p4a-numarray-inline-tiers.md
Original file line number Diff line number Diff line change
@@ -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<number>` 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<NumArray>` collector (guard-free consumers) is documented in the RFC and follows separately.
17 changes: 17 additions & 0 deletions crates/perry-codegen/src/expr/array_push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,24 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
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)
Expand Down
144 changes: 137 additions & 7 deletions crates/perry-codegen/src/expr/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)?
Expand Down Expand Up @@ -52,6 +73,119 @@ 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 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<String> {
let (raw, fallback_coerced) = lower_arithmetic_operand(ctx, expr)?;
if operand_needs_residual_coerce(ctx, expr, fallback_coerced) {
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<String> {
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<i64> {
let Expr::BigInt(raw) = expr else {
return None;
Expand Down Expand Up @@ -462,12 +596,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// 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)])
Expand Down
Loading
Loading