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
1 change: 1 addition & 0 deletions changelog.d/wpfacts-key-sign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Performance: an element read or store whose key the whole-program facts prove an integer Number inside the int32 range no longer normalizes the key at run time, and one whose key's interval is non-negative no longer tests its sign. wpfacts exports the key interval of every element site (reads, stores and called elements, joined over every copy of the site) as `key_signs`; codegen converts such a key with one exact `fptosi` (or takes the native integer it was widened from) instead of calling the shared normalizer, and a proven array index enters its element tier directly: no sign test, no `-1` select and no property-key arm. A key that may be negative keeps its sign test (the unsigned bound's miss arm, or the split's test), so it still reads the named property exactly; a key that may be a fraction, NaN, a string or a symbol has no interval and keeps the normalizer. `Array.prototype.at`, typed arrays and the index method clones are unchanged. Check mode (`PERRY_VERIFY_FACTS=1`) tests the claim at each consuming site and reports a false proof as "key sign" (exit 86); `PERRY_FACTS_SABOTAGE=keysign` is its negative control. `PERRY_FACTS_KEY_SIGN=0` turns the facts and their consumers off; the dump (`PERRY_FACTS_DUMP`) prints one `# key_sign` line per element site with an integer key.
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 @@ -399,6 +399,11 @@ pub struct ElisionFacts {
/// every copy, with its interval (wpfacts `ModuleBindings::element_keys`,
/// PR-C2; `expr::index_get::proven_array`).
pub element_keys: std::collections::BTreeMap<u32, (i64, i64)>,
/// Element sites (reads and stores, by `element_offset`) whose key is an
/// integer Number inside the int32 range at every copy, with its
/// interval (wpfacts `ModuleBindings::key_signs`;
/// `expr::index_key::key_fact`).
pub key_signs: std::collections::BTreeMap<u32, (i64, i64)>,
/// I32 at rest, PR B (wpfacts `ModuleBindings::rest_reads` /
/// `rest_writes` / `rest_births`; `expr::rest_elems`): element reads and
/// stores (by `element_offset`) whose every receiver is an at-rest
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/expr/index_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ pub(crate) fn lower_numeric_index_get_for_number_context(
false,
repair_slot.as_deref(),
proven,
super::index_key::key_fact(ctx, *element_offset),
)
.map(Some)
});
Expand Down Expand Up @@ -1461,6 +1462,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
true,
repair_slot.as_deref(),
proven,
super::index_key::key_fact(ctx, *element_offset),
)
})
});
Expand All @@ -1487,6 +1489,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
false,
repair_slot.as_deref(),
proven,
super::index_key::key_fact(ctx, *element_offset),
)
})
});
Expand Down
38 changes: 34 additions & 4 deletions crates/perry-codegen/src/expr/index_get/runtime_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,35 @@ pub(super) fn lower_array_index_get_via_canonical_i32_split(
preserve_claimed_receiver_fallback: bool,
receiver_slot: Option<&str>,
proven: Option<super::proven_array::Proven>,
key: Option<crate::expr::index_key::KeyFact>,
) -> Result<String> {
// Key signs: a key the facts prove a non-negative int32 is an array
// index at every evaluation. It takes the element tier with no
// normalizer and no sign test, the tier's own cold read serves every
// miss, and the property-key arm does not exist.
if let Some(fact) = key.filter(|k| k.nonneg) {
let idx_i32 = crate::expr::index_key::exact_i32(ctx, idx_double, fact);
return match proven {
Some(proven) => Ok(super::proven_array::lower_bounded(
ctx,
arr_box,
&idx_i32,
proven,
coerce_numeric_fallback,
None,
)),
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,
None,
),
};
}
let element_idx = ctx.new_block("aidx.canonical");
let runtime_idx = ctx.new_block("aidx.runtime_key");
let merge_idx = ctx.new_block("aidx.dynamic_merge");
Expand All @@ -76,10 +104,12 @@ pub(super) fn lower_array_index_get_via_canonical_i32_split(
// 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))
// Key signs: an int32 key that may be negative is its `i32` with no
// normalizer; its sign is tested as any other key's.
let idx_i32 = match key {
Some(fact) => crate::expr::index_key::exact_i32(ctx, idx_double, fact),
None if fold => crate::expr::index_key::normalize_unsigned(ctx, idx_double, Some(index)),
None => crate::expr::index_key::normalize(ctx, idx_double, Some(index)),
};
let negative_exit = fold.then(|| runtime_label.clone());
if fold {
Expand Down
114 changes: 114 additions & 0 deletions crates/perry-codegen/src/expr/index_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,116 @@ pub(crate) fn unsigned_bound() -> bool {
})
}

/// What wpfacts proves of the key of an element site (`ElisionFacts::
/// key_signs`, wpfacts `element_keys::collect_signs`): at every copy of the
/// site it is an integer Number inside the int32 range, so its `i32` is the
/// key exactly and the site needs no normalizer ([`exact_i32`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct KeyFact {
/// Its interval has `lo >= 0`: the key is an array index. The site
/// needs no sign test and no property-key arm.
pub(crate) nonneg: bool,
}

/// `PERRY_FACTS_KEY_SIGN=0` (`off`, `false`): no element site consumes its
/// key's interval; every site keeps its normalizer and its sign test.
pub(crate) fn key_sign_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
!matches!(
std::env::var("PERRY_FACTS_KEY_SIGN").as_deref(),
Ok("0") | Ok("off") | Ok("false")
)
})
}

/// THE query for the key of the element site at `element_offset` (a read or
/// a store of this module). A b1 call's synthesized read carries the body
/// module's offset (`FnCtx::rest_b1`): this module's facts say nothing of
/// its key.
pub(crate) fn key_fact(ctx: &FnCtx<'_>, element_offset: u32) -> Option<KeyFact> {
if element_offset == 0 || ctx.wpfacts_off || !key_sign_enabled() {
return None;
}
if matches!(ctx.rest_b1, Some((o, _)) if o == element_offset) {
return None;
}
let &(lo, hi) = ctx.wpfacts.key_signs.get(&element_offset)?;
(lo >= i64::from(i32::MIN) && hi <= i64::from(i32::MAX)).then_some(KeyFact { nonneg: lo >= 0 })
}

/// [`key_fact`] for the element store `e` (`a[k] = v` as an `IndexSet`).
pub(crate) fn store_key_fact(ctx: &FnCtx<'_>, e: &Expr) -> Option<KeyFact> {
match e {
Expr::IndexSet { element_offset, .. } => key_fact(ctx, *element_offset),
_ => None,
}
}

/// The `i32` of the key `key` (a double) under `fact`: the native integer it
/// was widened from, else one `fptosi` (exact: the key is an integer inside
/// the int32 range; `-0` is index 0, as its property key `"0"` is). Check
/// mode (`PERRY_VERIFY_FACTS=1`) tests the claim first ([`witness_sign`]).
pub(crate) fn exact_i32(ctx: &mut FnCtx<'_>, key: &str, fact: KeyFact) -> String {
crate::verify_facts::consumed(
crate::verify_facts::Fact::KeySign,
|| serde_json::json!({ "nonneg": fact.nonneg }),
);
crate::facts_consumed::used();
if crate::verify_facts::enabled() {
witness_sign(ctx, key, fact);
}
let source = ctx
.func
.blocks()
.iter()
.rev()
.find_map(|b| b.widened_i32_source(key));
source.unwrap_or_else(|| ctx.block().fptosi(DOUBLE, key, I32))
}

/// Check mode: the key `key` is an integer Number inside the int32 range
/// and, under `fact.nonneg`, not negative. A miss reports the false proof
/// ("key sign") and exits 86.
fn witness_sign(ctx: &mut FnCtx<'_>, key: &str, fact: KeyFact) {
crate::verify_facts::covered(
crate::verify_facts::Fact::KeySign,
|| serde_json::json!({ "nonneg": fact.nonneg }),
);
let blk = ctx.block();
let low = if fact.nonneg { "0.0" } else { "-2147483648.0" };
let ge = blk.fcmp("oge", key, low);
let le = blk.fcmp("ole", key, "2147483647.0");
let inside = blk.and(I1, &ge, &le);
// Sanitize before `fptosi`: a rejected conversion must not poison.
let safe = blk.select(I1, &inside, DOUBLE, key, "0.5");
let int = blk.fptosi(DOUBLE, &safe, I32);
let back = blk.sitofp(I32, &int, DOUBLE);
let exact = blk.fcmp("oeq", &back, key);
let ok = blk.and(I1, &inside, &exact);
crate::verify_facts::tick(ctx);
let miss = ctx.new_block("keysign.verify_miss");
let next = ctx.new_block("keysign.admitted");
let (miss_l, next_l) = (ctx.block_label(miss), ctx.block_label(next));
ctx.block().cond_br(&ok, &next_l, &miss_l);
ctx.current_block = miss;
let what = if fact.nonneg {
"an element key that is no non-negative int32"
} else {
"an element key that is no int32"
};
let site = crate::verify_facts::site_arg(ctx, what, crate::verify_facts::Fact::KeySign, &[]);
let _ = crate::verify_facts::check(
ctx,
I32,
"perry_wpfacts_check_miss",
&[(DOUBLE, key)],
&site,
);
ctx.block().unreachable();
ctx.current_block = next;
}

fn normalize_keyed(ctx: &mut FnCtx<'_>, key: &str, expr: Option<&Expr>, wrap: bool) -> String {
let source = ctx
.func
Expand Down Expand Up @@ -164,6 +274,10 @@ fn publish(ctx: &mut FnCtx<'_>) {
ctx.pending_helpers.push(f);
}

#[cfg(test)]
#[path = "index_key_sign_tests.rs"]
mod sign_tests;

#[cfg(test)]
mod tests {
use super::*;
Expand Down
91 changes: 91 additions & 0 deletions crates/perry-codegen/src/expr/index_key_sign_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! Key signs (`index_key::key_fact`, wpfacts `key_signs`): an element site
//! whose key the facts prove an int32 takes no normalizer, and one whose key
//! is proven non-negative takes no sign test and has no property-key arm.
//! Without the fact (or with an interval past int32) the site keeps both, so
//! each test can fail.

use std::sync::Arc;

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

const SRC: &str = "export function at(a: number[], i: number): number { return a[i]; }\n\
export function any(a: number[], i: number): unknown { return a[i]; }\n\
export function put(a: number[], i: number, v: number): void { a[i] = v; }\n";

/// IR of `SRC` with the key interval `key` on every `a[i]` site.
fn ir(key: Option<(i64, i64)>) -> String {
let mut elision = ElisionFacts::default();
if let Some(k) = key {
// SWC offsets are 1-based; a site's offset is its object's start.
for (i, _) in SRC.match_indices("a[i]") {
elision.key_signs.insert(i as u32 + 1, k);
}
}
let opts = CompileOptions {
emit_ir_only: true,
program_facts: ProgramFactsView {
elision: Arc::new(elision),
..Default::default()
},
..Default::default()
};
let src = SRC.to_string();
let m = 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, "ks.ts", &mut cache)
.expect("parses");
perry_hir::lower_module(&parsed.module, "ks", "ks.ts").expect("lowers")
})
.expect("spawn")
.join()
.expect("lowering thread");
String::from_utf8(compile_module(&m, opts).expect("fixture compiles")).expect("UTF-8 IR")
}

const NORMALIZE: &str = "call i32 @perry_array_index_normalize_i32";

#[test]
fn an_unproven_key_keeps_its_normalizer_and_the_property_key_arm() {
let without = ir(None);
assert!(without.contains(NORMALIZE), "{without}");
assert!(without.contains("aidx.runtime_key"), "{without}");
// An integer interval past the int32 range is no key fact.
let wide = ir(Some((0, 4_294_967_295)));
assert!(wide.contains(NORMALIZE), "{wide}");
assert!(wide.contains("aidx.runtime_key"), "{wide}");
}

#[test]
fn a_proven_index_takes_no_normalizer_and_no_sign_test() {
let with = ir(Some((0, 100)));
assert!(!with.contains(NORMALIZE), "no normalizer\n{with}");
assert!(
!with.contains("aidx.runtime_key"),
"no property-key arm\n{with}"
);
assert!(
!with.contains("ubound.cold"),
"no negative exit on the miss arm\n{with}"
);
assert!(
with.contains("fptosi double"),
"one exact conversion\n{with}"
);
// The bound stays: the key is not proven inside the Array.
assert!(with.contains("icmp ult i32"), "{with}");
}

#[test]
fn an_int32_key_that_may_be_negative_keeps_its_sign_arm() {
let with = ir(Some((-5, 100)));
assert!(!with.contains(NORMALIZE), "no normalizer\n{with}");
assert!(
with.contains("aidx.runtime_key"),
"a negative key reads the named property\n{with}"
);
// The miss arm still sends a negative key to the property-key read.
assert!(with.contains("ubound.cold"), "{with}");
}
12 changes: 11 additions & 1 deletion crates/perry-codegen/src/expr/index_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,9 @@ fn lower_array_index_set_via_runtime_key(
// #9394: the assignment's own `Throw` flag, carried to the runtime helper
// so a rejected element write is a TypeError only in strict code.
assignment_strict: bool,
// Key signs: what the facts prove of this store's key (`index_key::
// store_key_fact`).
key: Option<super::index_key::KeyFact>,
) -> Result<String> {
let layout_note_needed = array_store_needs_layout_note(ctx, object, value);
let value_is_numeric = is_numeric_expr(ctx, value);
Expand Down Expand Up @@ -342,7 +345,12 @@ fn lower_array_index_set_via_runtime_key(
// N10: every arm of that store is behind `idx <u capacity`, and
// every decline takes this one helper with the original key, so
// a widened native index needs no sign test in front of it.
let guard_idx_i32 = super::index_key::normalize_unsigned(ctx, &idx_double, Some(index));
// Key signs: a key the facts prove an int32 is that `i32` with
// no normalizer; a negative one declines as above.
let guard_idx_i32 = match key {
Some(fact) => super::index_key::exact_i32(ctx, &idx_double, fact),
None => super::index_key::normalize_unsigned(ctx, &idx_double, Some(index)),
};
super::index_set_guarded::emit_guarded_inbounds_array_store(
ctx,
&arr_box,
Expand Down Expand Up @@ -1053,6 +1061,7 @@ pub(crate) fn lower(
value.as_ref(),
"array[dynamic_numeric_index]",
assignment_strict,
super::index_key::store_key_fact(ctx, expr),
);
}
// Same dispatch tree as IndexGet: known array → fast inline,
Expand Down Expand Up @@ -1175,6 +1184,7 @@ pub(crate) fn lower(
value.as_ref(),
"array[dynamic_numeric_index]",
assignment_strict,
super::index_key::store_key_fact(ctx, expr),
);
};
let layout_note_needed = array_store_needs_layout_note(ctx, object, value);
Expand Down
6 changes: 5 additions & 1 deletion crates/perry-codegen/src/expr/proxy_reflect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
value,
receiver,
strict,
element_offset,
..
} => {
// Value records' check mode (DESIGN-p4l4 §10.1): a store into a copy.
Expand Down Expand Up @@ -1027,7 +1028,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
object: target.clone(),
index: key.clone(),
value: value.clone(),
element_offset: 0,
// The store's own site: its key facts apply
// (`index_key::store_key_fact`). An at-rest store
// was taken above (`rest_elems::write_floor`).
element_offset: *element_offset,
byte_offset: 0,
byte_end: 0,
},
Expand Down
Loading
Loading