Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5c74e1a
wpfacts: interval rule results are not widened, and a counter write o…
Nov 10, 2026
b6a30cb
codegen: a loop that reads a field-held Array on every iteration hold…
Nov 10, 2026
1231ef3
wpfacts: R1 test: the labeled control's test implies no clip
Nov 10, 2026
e4933d8
codegen: PR-D admission: an operator over a held read of Number eleme…
Nov 10, 2026
503c67c
codegen: PR-D negative control arrbasepoll (the poll does not re-deri…
Nov 10, 2026
631a293
Merge remote-tracking branch 'origin/main' into perf/range-sum-and-le…
Nov 10, 2026
93bb48b
wpfacts: R1 exactrule sabotage is a deterministic wrong sum (the oper…
Nov 10, 2026
13e81b5
verify_facts_s0: PR-D fixtures keep the board from growing in place, …
Nov 10, 2026
f0c5f36
check_file_size: stmt/loops.rs 8320 (PR-D loop hooks)
Nov 10, 2026
c094781
Merge remote-tracking branch 'origin/main' into perf/p4-arr-loop-base
Nov 10, 2026
d6f51dc
codegen: PR-D IR tests bound their loops by literals (an unproven par…
Nov 10, 2026
a0b546e
S0: R1 fixtures read through the b1 call with a local key (a loop-cou…
Nov 10, 2026
5bdd2e4
Merge remote-tracking branch 'origin/main' into perf/range-sum-and-le…
Nov 10, 2026
b5fd2aa
codegen: PR-D tests name FactsArrRep's im (merged main)
Nov 10, 2026
01df1e5
Merge remote-tracking branch 'origin/main' into perf/p4-arr-loop-base
Nov 10, 2026
2194066
Merge remote-tracking branch 'origin/perf/range-sum-and-leaving-write…
Nov 10, 2026
3fe1ade
codegen: an unsigned bound absorbs the element key's sign test (N10)
Nov 10, 2026
74d10c2
codegen: N10 unit tests assert the exit branch and the runtime arm's …
Nov 10, 2026
d4ca346
Merge remote-tracking branch 'origin/main' into perf/unsigned-bound
Nov 10, 2026
4ba7832
codegen: the any-key claim test expects N10's miss-arm sign test
Nov 10, 2026
fc5284f
Merge remote-tracking branch 'origin/main' into perf/unsigned-bound
Nov 10, 2026
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
1 change: 1 addition & 0 deletions changelog.d/codegen-unsigned-bound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Performance: an element read or store whose key is not proven non-negative no longer tests the key's sign in front of its bound. The element tiers already bound every element arm with one unsigned compare (`idx <u length`, `idx <u capacity`, `idx <u floor`), which declines a negative key because it is at least 2^31 unsigned, so the sign test moves to the miss arm: there a negative key leaves for the exact property-key read before the tier's cold element read, and a non-negative miss keeps the cold read. Every key takes the arm it took before (`-1` and `-2^31` are named properties, `-0` is element 0, NaN and `1.5` are named properties, holes and keys past the length read through the prototype). A native integer key (`k | 0`, an int32 local) is no longer mapped to `-1` by a select before the bound, on reads and on stores. A read whose key is proven in range has no bound and keeps its test. `PERRY_UNSIGNED_BOUND=0` turns it off; `PERRY_FACTS_SABOTAGE=unsignedbound` (the miss arm drops the exit for negative keys) is the negative control, which check mode (`PERRY_VERIFY_FACTS=1`) reports as "unsigned bound".
99 changes: 99 additions & 0 deletions crates/perry-codegen/src/expr/index_get/guarded_array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,104 @@ pub(super) fn lower_guarded_array_index_get(
require_numeric_layout,
coerce_numeric_fallback,
receiver_slot,
None,
)
}

/// [`lower_guarded_array_index_get`] for a key the caller has NOT tested
/// for sign (N10, `runtime_key`'s split): every hit is behind `idx <u n`,
/// and a miss sends a negative key to `negative_exit` before the cold read.
#[allow(clippy::too_many_arguments)]
pub(super) fn lower_guarded_array_index_get_bounded(
ctx: &mut FnCtx<'_>,
arr_box: &str,
idx_i32: &str,
block_prefix: &str,
require_numeric_layout: bool,
coerce_numeric_fallback: bool,
receiver_slot: Option<&str>,
negative_exit: Option<&str>,
) -> Result<String> {
lower_guarded_array_index_get_with_index(
ctx,
arr_box,
GuardedArrayIndex::Signed(idx_i32),
block_prefix,
require_numeric_layout,
coerce_numeric_fallback,
receiver_slot,
negative_exit,
)
}

/// N10 (CENSUS-residual cause i): the element tier a split key enters has
/// an unsigned bound in front of every element arm, so the split's sign
/// test can move to the tier's miss arm. The guarded tier bounds every hit
/// (`idx <u prefix`, `idx <u length` and `idx <u capacity`) unless the
/// observing guard of a diagnostic build takes the key first; a proven read
/// is bounded unless its key is proven in range
/// (`proven_array::bounds_key`).
pub(super) fn unsigned_bound_covers(proven: Option<super::proven_array::Proven>) -> bool {
crate::expr::index_key::unsigned_bound()
&& match proven {
Some(fact) => super::proven_array::bounds_key(fact),
None => !typed_feedback_emission_enabled(),
}
}

/// The start of a tier's miss arm under N10: a negative key leaves for
/// `negative_exit` (the split's runtime-key arm, which reads the original
/// key), so only a non-negative key reaches the cold read that follows,
/// exactly as when the split tested the sign first. Check mode
/// (`PERRY_VERIFY_FACTS=1`) tests that claim on the cold read's key; the
/// negative control `PERRY_FACTS_SABOTAGE=unsignedbound` drops the exit.
pub(super) fn emit_negative_exit(ctx: &mut FnCtx<'_>, idx_i32: &str, negative_exit: Option<&str>) {
let Some(exit) = negative_exit else {
return;
};
crate::verify_facts::consumed(
crate::verify_facts::Fact::UnsignedBound,
|| serde_json::json!({ "bound": "unsigned" }),
);
if !super::field_base::sabotaged("unsignedbound") {
let cold = ctx.new_block("ubound.cold");
let cold_l = ctx.block_label(cold);
let negative = ctx.block().icmp_slt(I32, idx_i32, "0");
ctx.block().cond_br(&negative, exit, &cold_l);
ctx.current_block = cold;
}
if !crate::verify_facts::enabled() {
return;
}
crate::verify_facts::covered(
crate::verify_facts::Fact::UnsignedBound,
|| serde_json::json!({ "bound": "unsigned" }),
);
let miss = ctx.new_block("ubound.verify_miss");
let next = ctx.new_block("ubound.admitted");
let (miss_l, next_l) = (ctx.block_label(miss), ctx.block_label(next));
crate::verify_facts::tick(ctx);
let nonnegative = ctx.block().icmp_sge(I32, idx_i32, "0");
ctx.block().cond_br(&nonnegative, &next_l, &miss_l);
ctx.current_block = miss;
let site = crate::verify_facts::site_arg(
ctx,
"a negative key at an element tier's cold read",
crate::verify_facts::Fact::UnsignedBound,
&[],
);
let key = ctx.block().sitofp(I32, idx_i32, DOUBLE);
let _ = crate::verify_facts::check(
ctx,
I32,
"perry_wpfacts_check_miss",
&[(DOUBLE, &key)],
&site,
);
ctx.block().unreachable();
ctx.current_block = next;
}

/// The same indexed read with a compiler proof of a nonnegative u32 index.
/// Retain its original JS number on the cold path, including indices > i32::MAX.
pub(super) fn lower_unsigned_array_index_get(
Expand All @@ -376,6 +471,7 @@ pub(super) fn lower_unsigned_array_index_get(
false,
false,
receiver_slot,
None,
)
}

Expand All @@ -384,6 +480,7 @@ enum GuardedArrayIndex<'a> {
Unsigned { i32_value: &'a str, number: &'a str },
}

#[allow(clippy::too_many_arguments)]
fn lower_guarded_array_index_get_with_index(
ctx: &mut FnCtx<'_>,
arr_box: &str,
Expand All @@ -392,6 +489,7 @@ fn lower_guarded_array_index_get_with_index(
require_numeric_layout: bool,
coerce_numeric_fallback: bool,
receiver_slot: Option<&str>,
negative_exit: Option<&str>,
) -> Result<String> {
let canonical = matches!(index, GuardedArrayIndex::Signed(_));
let (idx_i32, unsigned_index) = match index {
Expand Down Expand Up @@ -577,6 +675,7 @@ fn lower_guarded_array_index_get_with_index(
];
ctx.current_block = fallback_idx;
let cold_mode = ctx.block().phi(I64, &cold_inputs);
emit_negative_exit(ctx, idx_i32, negative_exit);
crate::expr::store_census::bump(ctx, crate::expr::store_census::ELEM_READ_FALLBACK);
let idx_box = unsigned_index
.map(str::to_owned)
Expand Down
36 changes: 33 additions & 3 deletions crates/perry-codegen/src/expr/index_get/proven_array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ pub(crate) fn lower_in_floor(
number: rep.elem != crate::codegen::FactsElemRep::Boxed,
receiver: Receiver::Field,
};
lower_rep(ctx, arr_box, idx_i32, read, false)
lower_rep(ctx, arr_box, idx_i32, read, false, None)
}

/// `e` is an element read the tier loads below a dense floor and whose
Expand Down Expand Up @@ -287,9 +287,35 @@ pub(super) fn lower(
fact: Proven,
numeric: bool,
) -> String {
lower_bounded(ctx, arr_box, idx_i32, fact, numeric, None)
}

/// The read under `fact` has an unsigned bound in front of its load: the
/// key is not proven below the floor (`idx <u capacity`) or below the fixed
/// length (`idx <u len`). Such a read admits no negative key, so a caller
/// may hand it one untested (N10, `guarded_array::unsigned_bound_covers`).
pub(super) fn bounds_key(fact: Proven) -> bool {
match fact {
Proven::Rep(read) => !read.in_floor,
Proven::Fixed(fact) => !fact.key_in_bounds,
}
}

/// [`lower`] whose key is not sign-tested when `negative_exit` is set
/// (`bounds_key` holds): the miss arm sends a negative key there first
/// (`guarded_array::emit_negative_exit`).
pub(super) fn lower_bounded(
ctx: &mut FnCtx<'_>,
arr_box: &str,
idx_i32: &str,
fact: Proven,
numeric: bool,
negative_exit: Option<&str>,
) -> String {
debug_assert!(negative_exit.is_none() || bounds_key(fact));
match fact {
Proven::Rep(read) => lower_rep(ctx, arr_box, idx_i32, read, numeric),
Proven::Fixed(fact) => lower_fixed(ctx, arr_box, idx_i32, fact, numeric),
Proven::Rep(read) => lower_rep(ctx, arr_box, idx_i32, read, numeric, negative_exit),
Proven::Fixed(fact) => lower_fixed(ctx, arr_box, idx_i32, fact, numeric, negative_exit),
}
}

Expand All @@ -300,6 +326,7 @@ fn lower_rep(
idx_i32: &str,
read: RepRead,
numeric: bool,
negative_exit: Option<&str>,
) -> String {
let r = read.rep;
crate::expr::store_census::bump(ctx, crate::expr::store_census::ELEM_READ_OTHER_TIER);
Expand Down Expand Up @@ -374,6 +401,7 @@ fn lower_rep(
ctx.block().br(&merge_label);

ctx.current_block = cold_idx;
super::guarded_array::emit_negative_exit(ctx, idx_i32, negative_exit);
let idx_box = ctx.block().sitofp(I32, idx_i32, DOUBLE);
// Mode 2: the exact arguments, no absence proof (`emit_read_cold`).
let cold = super::guarded_array::emit_read_cold(
Expand All @@ -394,6 +422,7 @@ fn lower_fixed(
idx_i32: &str,
fact: FactsElementRead,
numeric: bool,
negative_exit: Option<&str>,
) -> String {
crate::expr::store_census::bump(ctx, crate::expr::store_census::ELEM_READ_OTHER_TIER);
crate::verify_facts::consumed(
Expand Down Expand Up @@ -438,6 +467,7 @@ fn lower_fixed(
blk.cond_br(&hit, &merge_label, &cold_label);

ctx.current_block = cold_idx;
super::guarded_array::emit_negative_exit(ctx, idx_i32, negative_exit);
let idx_box = ctx.block().sitofp(I32, idx_i32, DOUBLE);
// Mode 2: the exact arguments, no absence proof (`emit_read_cold`).
let cold = super::guarded_array::emit_read_cold(
Expand Down
39 changes: 30 additions & 9 deletions crates/perry-codegen/src/expr/index_get/runtime_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use anyhow::Result;

use crate::types::{DOUBLE, I32};

use super::{lower_guarded_array_index_get, FnCtx};
use super::FnCtx;

pub(super) fn lower_array_index_get_via_runtime_key(
ctx: &mut FnCtx<'_>,
Expand Down Expand Up @@ -70,26 +70,47 @@ pub(super) fn lower_array_index_get_via_canonical_i32_split(
let runtime_label = ctx.block_label(runtime_idx);
let merge_label = ctx.block_label(merge_idx);

let idx_i32 = crate::expr::index_key::normalize(ctx, idx_double, Some(index));
let is_canonical_i32 = ctx.block().icmp_sge(I32, &idx_i32, "0");
ctx.block()
.cond_br(&is_canonical_i32, &element_label, &runtime_label);
// N10: when every element arm of the tier is behind `idx <u n`, the
// sign test moves off the hit: the bound declines a negative key (it is
// at least 2^31 unsigned), and the tier's miss arm sends a negative key
// to the runtime arm below before its own cold read. A non-negative miss
// keeps the tier's cold read, so every key takes the arm it takes today.
let fold = super::guarded_array::unsigned_bound_covers(proven);
let idx_i32 = if fold {
crate::expr::index_key::normalize_unsigned(ctx, idx_double, Some(index))
} else {
crate::expr::index_key::normalize(ctx, idx_double, Some(index))
};
let negative_exit = fold.then(|| runtime_label.clone());
if fold {
ctx.block().br(&element_label);
} else {
let is_canonical_i32 = ctx.block().icmp_sge(I32, &idx_i32, "0");
ctx.block()
.cond_br(&is_canonical_i32, &element_label, &runtime_label);
}

ctx.current_block = element_idx;
// A canonical key under a wpfacts proof takes the proven tier
// (`proven_array`); every other key keeps its exact runtime arm.
let element_value = match proven {
Some(fact) => {
super::proven_array::lower(ctx, arr_box, &idx_i32, fact, coerce_numeric_fallback)
}
None => lower_guarded_array_index_get(
Some(fact) => super::proven_array::lower_bounded(
ctx,
arr_box,
&idx_i32,
fact,
coerce_numeric_fallback,
negative_exit.as_deref(),
),
None => super::guarded_array::lower_guarded_array_index_get_bounded(
ctx,
arr_box,
&idx_i32,
"aidx.dynamic",
require_numeric_layout,
coerce_numeric_fallback,
receiver_slot,
negative_exit.as_deref(),
)?,
};
let element_end = ctx.block().label.clone();
Expand Down
10 changes: 9 additions & 1 deletion crates/perry-codegen/src/expr/index_get_claim_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,8 +830,16 @@ fn any_typed_dynamic_key_takes_the_numeric_tiers_when_it_is_an_array_index() {
assert!(ir.contains("aidx.canonical") && ir.contains("aidx.runtime_key"));
// The "integer-valued double in [0, 2^32)" test is the shared pure
// normalizer (`memory(none)`, a GC leaf) and a sign test of its result.
// N10: the tier's unsigned bound declines the normalizer's `-1`, and the
// sign test sits on the tier's miss arm (`icmp slt`, `ubound.cold`); with
// `PERRY_UNSIGNED_BOUND=0` it is the split's `icmp sge` again.
let sign_test = if crate::expr::index_key::unsigned_bound() {
ir.contains("icmp slt i32 %") && ir.contains("ubound.cold")
} else {
ir.contains("icmp sge i32 %")
};
assert!(
ir.contains("call i32 @perry_array_index_normalize_i32(") && ir.contains("icmp sge i32 %"),
ir.contains("call i32 @perry_array_index_normalize_i32(") && sign_test,
"the dynamic key must be normalized by the shared index helper:\n{ir}"
);
assert!(ir.contains("aidx.dynamic.guard.deref") && ir.contains("aidx.dynamic.fast"));
Expand Down
Loading
Loading