Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
90d8424
codegen: an index clone's parameter is covered by an I32 signature sl…
Nov 11, 2026
46b6828
typed signed index parameters: parity fixture, S0 arm, unit tests, ch…
Nov 11, 2026
138643c
Merge remote-tracking branch 'origin/main' into perf/typed-set
Nov 11, 2026
f7af377
WIP: codegen range proofs read the facts interval of proven int32 let…
Nov 11, 2026
89d88a9
WIP: interval witness, S0 arm localrange, fixtures and IR tests for t…
Nov 11, 2026
6128dbf
typed signed index parameters: the fixture's retained helper stays si…
Nov 11, 2026
b597b96
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
1d11007
Merge remote-tracking branch 'origin/main' into perf/typed-set
Nov 11, 2026
16baa31
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
9f7a3d4
changelog: facts local ranges
Nov 11, 2026
7c02239
WIP: an int32-proven element key is lowered as its i32 (int32_key)
Nov 11, 2026
9147d6c
local ranges fixture: a typed chain with signed parameters beside the…
Nov 11, 2026
0d29c4f
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
5f91420
typed signed index parameters: fixture comment
Nov 11, 2026
569ae2d
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
de203f4
rustfmt
Nov 11, 2026
918b02b
rustfmt
Nov 11, 2026
044d84c
typed signed index parameters: the fixture's at-rest board
Nov 11, 2026
dad7b8a
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
d3ee70e
WIP: an I32 lane read and a present at-rest element read are known int32
Nov 11, 2026
89372b2
local ranges IR test: a let with no local range proof
Nov 11, 2026
fd3c886
compile_env: PERRY_SIG_DIAG is a diagnostic read (exclusion with its …
Nov 11, 2026
37029b4
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
176a957
wpfacts signatures: refusal replaces admits (no dead function)
Nov 11, 2026
cc52341
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
9752c1e
Merge remote-tracking branch 'origin/main' into perf/typed-set
Nov 11, 2026
50c2b13
Merge branch 'perf/typed-set' into perf/int32-local-ranges
Nov 11, 2026
0d6f7ff
Merge remote-tracking branch 'origin/main' into perf/int32-local-ranges
Nov 11, 2026
c7bb05a
check_file_size: expr/mod.rs code-line count (2844)
Nov 11, 2026
be8a059
Revert the at-rest int32 range tweak: no measured effect on chess (x3…
Nov 11, 2026
c96e5d4
local ranges IR tests: compare against the baseline without an interval
Nov 11, 2026
2cab0e0
Merge remote-tracking branch 'origin/main' into perf/int32-local-ranges
Nov 11, 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-facts-local-ranges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Performance: the whole-program interval of an int32 local reaches codegen's range proofs. wpfacts already proved such intervals (a `let` read from an int Array is in the Array's element interval; a typed body's I32 parameter is in its signature's interval) but exported only "this local is an int32", so an element key built from such locals (`table[(old + 6) * 128 + square]`) was computed in doubles and normalized by a runtime call, and an at-rest element site keyed by a signed int32 local converted the key to a double and back. Now the key is an i32 chain bounded by one unsigned compare. `PERRY_FACTS_LOCAL_RANGES=0` restores the old lowering. Check mode witnesses the interval at every store into such a `let` (signature parameters are witnessed at their call sites).
30 changes: 29 additions & 1 deletion crates/perry-codegen/src/codegen/facts_int32_locals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@
//! (`repsel_benefit`). A double entering that slot is an exact int32, so it
//! converts with `fptosi` instead of ToInt32 (`store_from_double`).
//!
//! The proof's interval is a range proof of the local at every read
//! (`expr::range_facts::facts_local_range`, `PERRY_FACTS_LOCAL_RANGES=0`
//! turns that off): an index or an integer chain built from it needs no
//! dominating guard.
//!
//! Check mode (`PERRY_VERIFY_FACTS=1`) witnesses every store: the stored value
//! must pass the exact-i32 test (`emit_typed_i32_plain_guard_and_raw`). Stores the
//! must pass the exact-i32 test (`emit_typed_i32_plain_guard_and_raw`) and lie
//! in the interval the range proofs read. Stores the
//! ordinary lowering would compute natively in i32 take the double path
//! there, so no store escapes the witness (`checked`).
//!
Expand Down Expand Up @@ -96,6 +102,24 @@ pub(crate) fn witness_exact(ctx: &mut crate::expr::FnCtx<'_>, value: &str, id: u
let nz = ctx.block().icmp_eq(I64, &bits, "-9223372036854775808");
exact = ctx.block().or(I1, &exact, &nz);
}
// The interval the body's range proofs read for this local
// (`range_facts::facts_local_range`): the stored value lies in it. A
// tolerated `-0` compares as 0.
if let Some(range) = crate::expr::facts_local_range(ctx, id) {
let blk = ctx.block();
let ge = blk.fcmp(
"oge",
value,
&crate::nanbox::double_literal(range.min as f64),
);
let le = blk.fcmp(
"ole",
value,
&crate::nanbox::double_literal(range.max as f64),
);
let inside = blk.and(I1, &ge, &le);
exact = blk.and(I1, &exact, &inside);
}
crate::verify_facts::tick(ctx);
let miss = ctx.new_block("int32.input.verify_miss");
let next = ctx.new_block("int32.input.admitted");
Expand Down Expand Up @@ -141,3 +165,7 @@ pub(crate) fn store_from_double(
#[cfg(test)]
#[path = "facts_int32_locals_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "facts_local_ranges_tests.rs"]
mod range_tests;
112 changes: 112 additions & 0 deletions crates/perry-codegen/src/codegen/facts_local_ranges_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! The whole-program interval of a proven int32 `let` is a range proof
//! (`expr::range_facts::facts_local_range`): a key built from the local is
//! an int32 by the interval, so it is computed in i32 and handed to the
//! element read as it is. Without the interval the key is a double chain
//! and goes through the index normalizer.

use std::sync::Arc;

use crate::codegen::{ElisionFacts, FactsRep, ProgramFactsView};
use crate::{compile_module, CompileOptions};
use perry_hir::Module;

/// `k` has no local range proof (an element of an ordinary Array): only
/// the facts' interval bounds it.
const SRC: &str = "export function f(t: number[], u: number[], i: number): number {\n\
const k = u[i];\n\
return t[k * 2 + 1] ?? -1;\n\
}\n";

fn lower(src: &str) -> Module {
let src = src.to_string();
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(move || {
let mut cache = perry_diagnostics::SourceCache::new();
let parsed = perry_parser::parse_typescript_with_cache(&src, "lr.ts", &mut cache)
.expect("parses");
perry_hir::lower_module(&parsed.module, "lr", "lr.ts").expect("lowers")
})
.expect("spawn")
.join()
.expect("lowering thread")
}

/// The HIR id of the `let` named `name`.
fn let_id(m: &Module, name: &str) -> u32 {
let text = format!("{m:?}");
let mut rest = text.as_str();
while let Some(i) = rest.find("Let { id: ") {
rest = &rest[i + 10..];
let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let j = rest.find("name: \"").expect("named let");
if rest[j + 7..].starts_with(&format!("{name}\"")) {
return num.parse().expect("id");
}
}
panic!("no let {name}");
}

/// The IR of `f` with `k` proven int32, and its interval `range` when given.
fn ir_of_f(range: Option<(i64, i64)>) -> String {
let m = lower(SRC);
let mut elision = ElisionFacts::default();
let k = let_id(&m, "k");
elision.int32_inputs.insert(k);
elision.local_reps.insert(k, FactsRep::I32);
if let Some(range) = range {
elision.int32_ranges.insert(k, range);
}
let opts = CompileOptions {
emit_ir_only: true,
program_facts: ProgramFactsView {
elision: Arc::new(elision),
..Default::default()
},
..Default::default()
};
let ir = String::from_utf8(compile_module(&m, opts).expect("compiles")).expect("UTF-8");
let start = ir
.lines()
.position(|l| l.starts_with("define") && l.contains("perry_fn_lr__f"))
.expect("f defined");
ir.lines()
.skip(start)
.take_while(|l| *l != "}")
.collect::<Vec<_>>()
.join("\n")
}

fn normalizer_calls(ir: &str) -> usize {
ir.lines()
.filter(|l| l.contains("call") && l.contains("perry_array_index_normalize_i32"))
.count()
}

/// The normalizer calls of `f` when `k` has no interval: one for `t[k * 2 +
/// 1]` (and one for `u[i]`, whose key is a double parameter in every arm).
fn baseline() -> usize {
let n = normalizer_calls(&ir_of_f(None));
assert!(
n >= 1,
"the key of `t[k * 2 + 1]` is normalized without an interval"
);
n
}

#[test]
fn a_key_built_from_a_ranged_int32_let_is_an_i32_key() {
// `k` in [-3, 12]: `k * 2 + 1` is in [-5, 25], an int32 of either sign:
// its normalizer call is gone, and the key is an i32 chain.
let f = ir_of_f(Some((-3, 12)));
assert_eq!(normalizer_calls(&f) + 1, baseline(), "{f}");
assert!(f.contains("mul i32") || f.contains("shl i32"), "{f}");
}

#[test]
fn an_interval_whose_chain_leaves_int32_is_no_i32_key() {
// `k` up to 2^31 - 1: `k * 2 + 1` may pass int32, so the key keeps its
// double (a wrapped i32 would name another element).
let f = ir_of_f(Some((0, i64::from(i32::MAX))));
assert_eq!(normalizer_calls(&f), baseline(), "{f}");
}
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1712,6 +1712,8 @@ pub(super) fn compile_function(
ctx.sig_return = sig_return;
ctx.nonnegative_integer_locals
.extend(super::signature_entry::nonnegative_params(f, plan));
ctx.int_range_facts
.extend(super::signature_entry::param_range_facts(f, plan));
}
let wrapper_name = format!("__perry_wrap_{}", public_llvm_name);
super::arguments::materialize_arguments_object(
Expand Down
5 changes: 4 additions & 1 deletion crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -999,7 +999,10 @@ pub(super) fn compile_method(
buffer_hazard_reasons: HashMap::new(),
native_i32_aliases: HashMap::new(),
int_range_aliases: HashMap::new(),
int_range_facts: Vec::new(),
int_range_facts: match sig {
Some(tm) => super::signature_entry::param_range_facts(method, &tm.plan),
None => Vec::new(),
},
next_loop_proof_scope_id: 0,
nonnegative_integer_locals: nonnegative_param_ids,
elided_arguments: HashMap::new(),
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/codegen/program_facts_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,11 @@ pub struct ElisionFacts {
pub local_reps: std::collections::BTreeMap<u32, FactsRep>,
/// The `int32_inputs` whose interval is non-negative.
pub nonneg_int32_inputs: std::collections::BTreeSet<u32>,
/// Each of the `int32_inputs` with its interval (wpfacts
/// `BindingFacts::int_range`): every value the binding ever holds lies
/// in it. Codegen's range proofs read it for a proven int32 `let`
/// (`expr::range_facts::facts_local_range`).
pub int32_ranges: std::collections::BTreeMap<u32, (i64, i64)>,
/// Local id → it holds only strings and ordinary Arrays: `x.length` is a
/// Number.
pub length_number_inputs: std::collections::BTreeSet<u32>,
Expand Down
29 changes: 29 additions & 0 deletions crates/perry-codegen/src/codegen/signature_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,35 @@ pub(crate) fn slot_i32_params(f: &Function, plan: &SpecFnPlan) -> HashSet<u32> {
.collect()
}

/// `F$sig`'s parameters with the interval the signature proves (T1): an I32
/// parameter in its i32 slot (never written: plan selection demoted written
/// parameters) holds an integer of its interval for the whole body, so the
/// body's range proofs start from it (`FnCtx::int_range_facts`, under a
/// scope no loop ends). The interval is witnessed at every typed site and at
/// the boxed entry (`witness_value`). Empty with
/// `PERRY_FACTS_LOCAL_RANGES=0`.
pub(crate) fn param_range_facts(f: &Function, plan: &SpecFnPlan) -> Vec<crate::expr::IntRangeFact> {
let Some(sig) = plan.signature.as_ref() else {
return Vec::new();
};
if !crate::expr::facts_local_ranges() {
return Vec::new();
}
f.params
.iter()
.zip(plan.reps.iter())
.zip(sig.ranges.iter())
.filter_map(|((p, rep), range)| {
let (min, max) = (*range)?;
matches!(rep, SpecParamRep::I32).then_some(crate::expr::IntRangeFact {
local_id: p.id,
scope_id: u32::MAX,
range: crate::expr::IntRange { min, max },
})
})
.collect()
}

/// `F$sig`'s parameters that seed `nonnegative_integer_locals` (T1): an I32
/// parameter in its i32 slot whose interval has `lo >= 0` (the slot is
/// never written: plan selection demoted written parameters).
Expand Down
26 changes: 26 additions & 0 deletions crates/perry-codegen/src/expr/index_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,32 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let repair_slot = receiver_repair_slot(ctx, object);
let confined = confined_receiver(ctx, object);
let proven = proven_array::fact(ctx, object, *element_offset);
// A key the range proofs bound to int32 (of either sign)
// is lowered as that i32: the split takes it as a native
// key, with no normalizer call (`int32_key`).
if super::int32_key::proven(ctx, index) {
return rooting::with_operands_rooted_across(
ctx,
&[object],
&[index],
|ctx| super::int32_key::lower(ctx, index),
|ctx, vals, key| {
with_confined_read(ctx, confined, |ctx| {
lower_array_index_get_via_canonical_i32_split(
ctx,
&vals[0],
&key,
index,
false,
false,
false,
repair_slot.as_deref(),
proven,
)
})
},
);
}
return rooting::with_operands_rooted(ctx, &[object, index], |ctx, vals| {
with_confined_read(ctx, confined, |ctx| {
lower_array_index_get_via_canonical_i32_split(
Expand Down
8 changes: 7 additions & 1 deletion crates/perry-codegen/src/expr/index_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,13 @@ fn lower_array_index_set_via_runtime_key(
&[object],
&[index, value],
|ctx| {
let idx_double = lower_expr(ctx, index)?;
// A key the range proofs bound to int32 is lowered as that i32
// (`int32_key`): the guarded store takes it as a native key.
let idx_double = if super::int32_key::proven(ctx, index) {
super::int32_key::lower(ctx, index)?
} else {
lower_expr(ctx, index)?
};
let value_needs_barrier = array_store_needs_write_barrier(ctx, value);
let (val_double, val_bits) = lower_value_for_dynamic_index_set(
ctx,
Expand Down
38 changes: 38 additions & 0 deletions crates/perry-codegen/src/expr/int32_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! An element key the body's range proofs bound to the int32 range, of
//! either sign.
//!
//! The canonical key split (`index_get`'s runtime-key arm, `index_set`'s
//! runtime-key store) admits a key that is only statically numeric through
//! the shared normalizer (`index_key`, a call) unless the key's lowered
//! double has a native i32 source. A key whose range is proven inside int32
//! ([`proven`]: `i32_fast_path::is_known_i32_range`, so a loop fact, a typed
//! body's parameter interval, or the whole-program interval of an int32
//! `let`, `range_facts::facts_local_range`) is an exact int32 at every
//! evaluation, so it is lowered as one ([`lower`]: its i32 chain, else one
//! conversion of its double) and handed to the split as that source. The
//! split's unsigned bound (N10) then declines a negative key onto the arm
//! that reads the original value, as it does for any native key.
//!
//! `PERRY_FACTS_LOCAL_RANGES=0` keeps the double key and the normalizer.

use anyhow::Result;
use perry_hir::Expr;

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

/// `index` is an int32 at every evaluation by the body's range proofs, and
/// the unsigned bound is on (the split then needs no sign test for it).
pub(crate) fn proven(ctx: &FnCtx<'_>, index: &Expr) -> bool {
super::facts_local_ranges()
&& super::index_key::unsigned_bound()
&& !ctx.wpfacts_off
&& super::i32_fast_path::is_known_i32_range(ctx, index)
}

/// The key `index` ([`proven`]) as the double whose source is its i32
/// (`sitofp`): `index_key::normalize_unsigned` hands that i32 over as it is.
pub(crate) fn lower(ctx: &mut FnCtx<'_>, index: &Expr) -> Result<String> {
let idx = super::lower_expr_as_i32(ctx, index)?;
Ok(ctx.block().sitofp(I32, &idx, DOUBLE))
}
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ pub(crate) use range_facts::{
record_int_facts_for_update, record_local_value_alias_for_write, while_condition_range_fact,
IntRange, IntRangeFact,
};
pub(crate) use range_facts::{facts_local_range, facts_local_ranges};
pub(crate) use strings::emit_string_literal_global;
pub(crate) use typed_feedback::{
emit_typed_feedback_record_call, emit_typed_feedback_register_site, native_region_slug,
Expand Down Expand Up @@ -3365,6 +3366,7 @@ pub(crate) use index_get::guarded_array::emit_spread_stack_admission;
#[cfg(test)]
mod index_get_claim_tests;
pub(crate) mod index_key;
pub(crate) mod int32_key;
#[cfg(test)]
mod kind_entry_selection_tests;
#[cfg(test)]
Expand Down
35 changes: 35 additions & 0 deletions crates/perry-codegen/src/expr/range_facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,45 @@ fn int_range_for_local(
// parameter. Without it one unbounded parameter leaf poisons the whole
// index expression and demotes EVERY array access in the function.
let result = result.or_else(|| ctx.param_int_ranges.get(&id).copied());
// The whole-program interval of a proven int32 `let`: the weakest proof,
// read only when nothing local bounds the local.
let result = result.or_else(|| facts_local_range(ctx, id));
seen.remove(&id);
result
}

/// `PERRY_FACTS_LOCAL_RANGES=0` (`off`, `false`): codegen's range proofs do
/// not read the whole-program interval of a proven int32 `let` or of a typed
/// body's I32 parameter (main before this rule).
pub(crate) fn facts_local_ranges() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
!matches!(
std::env::var("PERRY_FACTS_LOCAL_RANGES").as_deref(),
Ok("0") | Ok("off") | Ok("false")
)
})
}

/// The whole-program interval (wpfacts `BindingFacts::int_range`) of local
/// `id` when this body holds it as a proven int32 `let`
/// (`FnCtx::facts_int32_locals`): every value the local ever holds is an
/// integer in it, at every read, so it is a range proof that needs no
/// dominating guard. Check mode witnesses the interval at every store into
/// such a local (`codegen::facts_int32_locals::witness_exact`). The negative
/// control `PERRY_FACTS_SABOTAGE=localrange` claims the interval's lower
/// bound alone.
pub(crate) fn facts_local_range(ctx: &FnCtx<'_>, id: u32) -> Option<IntRange> {
if !facts_local_ranges() || ctx.wpfacts_off || !ctx.facts_int32_locals.contains(&id) {
return None;
}
let &(min, max) = ctx.wpfacts.int32_ranges.get(&id)?;
if std::env::var("PERRY_FACTS_SABOTAGE").as_deref() == Ok("localrange") {
return Some(IntRange { min, max: min });
}
Some(IntRange { min, max })
}

fn int_range_expr_inner(
ctx: &FnCtx<'_>,
expr: &Expr,
Expand Down
1 change: 1 addition & 0 deletions crates/perry/src/commands/compile/compile_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub(super) const COMPILE_ENV_VARS: &[&str] = &[
"PERRY_FACTS_INT_RANGES",
"PERRY_FACTS_INT_DEMAND",
"PERRY_FACTS_COMPARE_DEMAND",
"PERRY_FACTS_LOCAL_RANGES",
"PERRY_FACTS_AT_REST",
"PERRY_REST_ELEMS",
"PERRY_REST_FIELDS",
Expand Down
Loading
Loading