Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions changelog.d/numeric-range-add-single-pass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
**The numeric-window add kernel is one fused pass with a resume contract, and
`bench_numeric_array_downgrade` now beats node** (16 ms → 3 ms against node's
4 ms on an idle machine; checksums identical).

The mixed-layout tier for `arr[i] = arr[i] + delta` over `any[]` windows
(`match_numeric_range_add_loop`) was doing its job — the diagnosis that led
here first established that the benchmark's 3.6× was almost entirely the
declared-type effect and not the heterogeneity, then that the loop was already
claimed by this purpose-built tier. The whole cost sat in its runtime kernel:
two full passes over a 1MB window (validate every slot, then mutate every
slot) with a branchy NaN-box decode per slot per pass, ~2.9 ns per element
against node's 0.64.

The all-or-nothing contract those two passes bought was stronger than the
source semantics require. Each element receives exactly one `+ delta` whether
the kernel or the ordinary loop applies it, so mutating up to the first
non-number and letting the ordinary loop resume there is observably
identical. The kernel now returns `>= 0` (window done), `-1` (receiver-level
decline, nothing mutated), or `<= -2` (slots `[start, k)` updated, resume at
`k = -ret - 2`); the lowering seeds the counter with the resume index before
entering the fallback loop — safe because `lower_for` lowers the init before
any matcher runs, so the fallback cannot re-run it and double-apply. The
double lane is decoded first (after the first call every slot holds a boxed
double); the int lane keeps the class-ref exclusion in the shared decoder.

The resume path is pinned by tests the benchmark itself never exercises,
since its windows are entirely numeric: a non-number mid-window gets node's
exact semantics — one increment per element, concatenation where `+`
concatenates (`"[object Object]1"`, `"mid1"`), NaN slots staying NaN — under
normal and forced-evacuation runs.

Also names the packed-f64 versioned matcher's one silent gate
(`no_length_hoist`): a loop whose bound is not `arr.length` exited before any
named reject could fire, which made every literal- or parameter-bounded loop
invisible to `PERRY_PACKED_LOOP_TRACE` — the gap that forced this diagnosis
through binary instrumentation instead of one trace run.
58 changes: 54 additions & 4 deletions crates/perry-codegen/src/stmt/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,13 @@ fn lower_numeric_range_add_loop(
&[(DOUBLE, &arr_box), (DOUBLE, &start_box), (DOUBLE, &delta)],
),
};
// Result contract (see `array_numeric_range_add_impl`):
// ret >= 0 -- whole window done; ret is the counter on exit.
// ret == -1 -- receiver-level decline; nothing mutated.
// ret <= -2 -- slots [start, k) mutated, k = -ret - 2; the
// ordinary loop RESUMES at k (the element at k is
// not a plain number, so its `+` may concatenate or
// dispatch valueOf -- generic code handles it).
let succeeded = ctx.block().icmp_sge(I64, &result, "0");
let success_idx = ctx.new_block("numeric.range_add.success");
let fallback_idx = ctx.new_block("numeric.range_add.fallback");
Expand All @@ -502,6 +509,36 @@ fn lower_numeric_range_add_loop(
ctx.block().br(&merge_label);

ctx.current_block = fallback_idx;
{
// ret <= -2: seed the counter with the resume index before entering
// the ordinary loop. ret == -1 keeps the counter untouched (it still
// holds the loop's start value).
let is_partial = ctx.block().icmp_slt(I64, &result, "-1");
let neg = ctx.block().sub(I64, "-2", &result);
let cur_i32 = if let Some(slot) = ctx.i32_counter_slots.get(&matched.counter_id).cloned() {
let cur = ctx.block().load(I32, &slot);
let resume = ctx.block().trunc(I64, &neg, I32);
let seeded = ctx
.block()
.select(crate::types::I1, &is_partial, I32, &resume, &cur);
ctx.block().store(I32, &seeded, &slot);
Some(seeded)
} else {
None
};
if let Some(slot) = ctx.locals.get(&matched.counter_id).cloned() {
let cur = ctx.block().load(DOUBLE, &slot);
let resume_d = if let Some(seeded) = &cur_i32 {
ctx.block().sitofp(I32, seeded, DOUBLE)
} else {
let d = ctx.block().sitofp(I64, &neg, DOUBLE);
let cur2 = cur.clone();
ctx.block()
.select(crate::types::I1, &is_partial, DOUBLE, &d, &cur2)
};
ctx.block().store(DOUBLE, &resume_d, &slot);
}
}
lower_for_after_init(
ctx,
init,
Expand Down Expand Up @@ -872,7 +909,11 @@ fn emit_packed_numeric_accumulator_admission(
offset_reads_inlined: bool,
) -> PackedAccumulatorScope {
let accumulators = super::stable_packed_accumulator::collect_numeric_accumulators(
ctx, body, array_id, counter_id, offset_reads_inlined,
ctx,
body,
array_id,
counter_id,
offset_reads_inlined,
);
// Integer (`c++`) accumulators admit independently of the float set —
// a pure count loop has no float accumulator at all.
Expand Down Expand Up @@ -4962,9 +5003,18 @@ fn match_packed_f64_versioned_loop(
}
let ordinary_hoist =
condition.and_then(|cond| classify_for_length_hoist(ctx, cond, update, body));
let hoist = ordinary_hoist.or_else(|| {
condition.and_then(|cond| classify_for_length_hoist_impl(ctx, cond, update, body, true))
})?;
let hoist = ordinary_hoist
.or_else(|| {
condition.and_then(|cond| classify_for_length_hoist_impl(ctx, cond, update, body, true))
})
.or_else(|| {
// #9253 flagged this as the one SILENT gate in the matcher: a loop
// whose bound is not `arr.length` exits here before any of the named
// rejects below can fire, which made every literal- or param-bounded
// loop invisible to the trace.
let _ = packed_loop_reject("no_length_hoist");
None
})?;
if !matches!(hoist.op, perry_hir::CompareOp::Lt) || hoist.lhs_addend != 0 {
return packed_loop_reject("compare_op_not_lt");
}
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/array/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,7 @@ pub extern "C" fn js_array_numeric_value_to_raw_f64(value: f64) -> f64 {
}

#[inline]
fn canonical_raw_f64(value: f64) -> f64 {
pub(crate) fn canonical_raw_f64(value: f64) -> f64 {
if value.is_nan() {
f64::NAN
} else {
Expand Down
50 changes: 37 additions & 13 deletions crates/perry-runtime/src/array/numeric_range.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ use std::ptr;

/// Try to perform `arr[i] = arr[i] + delta` over a dense numeric window.
///
/// This is intentionally transactional: the first pass validates the actual
/// runtime receiver and every source slot, and only then does the second pass
/// mutate. Returning `-1` means "run the ordinary JS loop"; no slot has been
/// changed in that case. A non-negative return is the counter value the source
/// loop would have on exit.
/// Receiver-level validation happens up front; element mutation is a single
/// fused pass. Returns:
/// * `>= 0` -- the whole window was numeric and is updated; the value is
/// the counter the source loop would have on exit.
/// * `-1` -- receiver-level decline (wrong type, frozen, descriptors,
/// out-of-range window); NO slot has been changed.
/// * `<= -2` -- slots `[start, k)` were updated and slot `k = -ret - 2` is
/// not a plain number; the caller resumes the ordinary loop at `k`. This
/// replaced the old all-or-nothing two-pass contract: each element gets
/// exactly one `+ delta` either way, so a partial update plus resume is
/// observably identical and halves the memory traffic.
fn array_numeric_range_add_impl(receiver: f64, start: f64, end: Option<f64>, delta: f64) -> i64 {
let receiver_value = crate::value::JSValue::from_bits(receiver.to_bits());
if !receiver_value.is_pointer() {
Expand Down Expand Up @@ -70,16 +76,34 @@ fn array_numeric_range_add_impl(receiver: f64, start: f64, end: Option<f64>, del
return i64::from(start);
}
let elements = (arr as *mut u8).add(std::mem::size_of::<ArrayHeader>()) as *mut u64;
for index in start..end {
if value_bits_to_number(ptr::read(elements.add(index as usize))).is_none() {
return -1;
}
}
// One fused pass instead of validate-then-mutate. The all-or-nothing
// contract the two-pass version provided was stronger than the source
// semantics require: each element gets exactly one `+ delta` either
// way, so mutating up to the first non-number and letting the
// ordinary loop RESUME there (return `-(index) - 2`) is observably
// identical -- and halves the memory traffic, which on a 1MB window
// was the whole cost. The double lane is checked first: after the
// first call every slot holds a boxed double, so the int lane only
// runs on freshly built integer arrays.
for index in start..end {
let slot = elements.add(index as usize);
let number = value_bits_to_number(ptr::read(slot))
.expect("numeric range was validated before mutation");
// GC_STORE_AUDIT(POINTER_FREE): both operands were proven numeric,
let bits = ptr::read(slot);
let tag = bits >> 48;
// Genuine boxed double: anything outside the NaN-box tag band.
// This is `value_bits_to_number`'s double arm, fused inline.
let number = if !(0x7FF9..=0x7FFF).contains(&tag) {
super::header::canonical_raw_f64(f64::from_bits(bits))
} else if let Some(number) = value_bits_to_number(bits) {
// Int-tagged (minus registered class ids) via the shared
// decoder, so the class-ref exclusion stays in one place.
number
} else {
// Not a plain number: the ordinary loop takes over from here.
// Slots [start, index) are already updated, which the caller's
// resume contract accounts for.
return -i64::from(index) - 2;
};
// GC_STORE_AUDIT(POINTER_FREE): the operand was proven numeric,
// so the replacement is an unboxed IEEE-754 value.
ptr::write(slot, (number + delta).to_bits());
}
Expand Down
29 changes: 26 additions & 3 deletions crates/perry-runtime/src/array/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,13 @@ fn numeric_range_add_updates_only_the_validated_window_of_a_mixed_array() {
}

#[test]
fn numeric_range_add_failure_is_transactional() {
fn numeric_range_add_element_failure_mutates_the_prefix_and_reports_the_resume_index() {
// The kernel is a single fused pass. A non-number mid-window no longer
// rolls the whole call back: slots before it keep their update (they
// received exactly the one `+ delta` the source loop owed them), the
// return encodes the resume index (`-index - 2`), and the caller's
// ordinary loop takes over from there. The slot itself and everything
// after it are untouched.
let mut arr = js_array_alloc(3);
arr = js_array_push_f64(arr, 10.0);
arr = js_array_push_f64(arr, 20.0);
Expand All @@ -885,10 +891,27 @@ fn numeric_range_add_failure_is_transactional() {
js_array_set_f64(arr, 1, marker);

let receiver = boxed_pointer(arr as *mut u8);
assert_eq!(js_array_numeric_range_add(receiver, 0.0, 3.0, 7.0), -1);
assert_eq!(js_array_get_f64(arr, 0), 10.0);
// Stops at index 1: -(1) - 2 == -3.
assert_eq!(js_array_numeric_range_add(receiver, 0.0, 3.0, 7.0), -3);
assert_eq!(js_array_get_f64(arr, 0), 17.0);
assert_eq!(js_array_get_f64(arr, 1).to_bits(), marker.to_bits());
assert_eq!(js_array_get_f64(arr, 2), 30.0);

// RECEIVER-level failures stay fully transactional: nothing was written.
// (The frozen-array test below pins the same for OBJ_FLAG_FROZEN.)
let not_an_array = 4.0_f64;
assert_eq!(js_array_numeric_range_add(not_an_array, 0.0, 3.0, 7.0), -1);

// A failure at index 0 mutates nothing and resumes at 0: -(0) - 2 == -2.
assert_eq!(js_array_numeric_range_add(receiver, 1.0, 3.0, 7.0), -3);
let mut arr2 = js_array_alloc(2);
arr2 = js_array_push_f64(arr2, 1.0);
js_array_set_f64(arr2, 0, marker);
arr2 = js_array_push_f64(arr2, 2.0);
let receiver2 = boxed_pointer(arr2 as *mut u8);
assert_eq!(js_array_numeric_range_add(receiver2, 0.0, 2.0, 7.0), -2);
assert_eq!(js_array_get_f64(arr2, 0).to_bits(), marker.to_bits());
assert_eq!(js_array_get_f64(arr2, 1), 2.0);
}

#[test]
Expand Down
Loading
Loading