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-p3-b1-canonical-numbers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Performance: an element read whose value the whole-program facts prove a plain Number (every value stored into the array is a program Number or the audited Number result of a builtin) is compared with a bare `fcmp`, used as an arithmetic operand without an "is a Number" test, and passed to a Number parameter without an INT32 conversion at entry. A read that may also be `undefined` (a hole, or an index past the end) keeps exact JS equality: `undefined === undefined` is true and `NaN === NaN` false. Each builtin's Number result is audited against the runtime function that produces it (`perry-wpfacts/NUM_SEEDS.md`; a test keeps the table complete). `PERRY_VERIFY_FACTS=1` checks every trusted value is a plain double. `PERRY_FACTS_CANONICAL_NUMBERS=0` turns it off. A runtime built with `PERRY_FACTS_SABOTAGE=canonnum` (`scripts/p3-canonnum-runtime.sh`) is the negative control.
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/codegen/facts_number_locals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,16 @@ pub(crate) fn none() -> &'static HashSet<u32> {

/// The plain double of a Number argument: an INT32 box converts, every other
/// Number is its own bits.
///
/// P3 B1, C4 (DESIGN-p3 rev 3.1 §2.3): a Number the facts prove is a plain
/// double (every value reaching the parameter is a program Number or an
/// audited builtin NUM, `perry-wpfacts/NUM_SEEDS.md`), so with
/// `PERRY_FACTS_CANONICAL_NUMBERS` on the argument is taken as it is. The
/// entry witness (`emit_param_witnesses`) checks it in check mode.
pub(crate) fn emit_entry_number(blk: &mut crate::block::LlBlock, arg: &str) -> String {
if crate::expr::facts_canonical::enabled() {
return arg.to_string();
}
let is_int = super::typed_entry::emit_int32_box_test(blk, arg);
let bits = blk.bitcast_double_to_i64(arg);
let low = blk.trunc(I64, &bits, I32);
Expand Down
19 changes: 15 additions & 4 deletions crates/perry-codegen/src/codegen/facts_number_locals_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,21 @@ fn proven_bindings_take_raw_double_ops_and_own_no_root_slot() {
0,
"a proven Number binding never holds a pointer:\n{body}"
);
assert!(
body.contains(crate::nanbox::INT32_TAG_I64),
"a proven parameter converts an INT32 box at entry:\n{body}"
);
// P3 B1, C4: a proven Number is a plain double (the NUM fact is audited,
// `perry-wpfacts/NUM_SEEDS.md`), so the parameter takes its argument's bits
// with no INT32 decode at entry; the entry witness checks it in check
// mode. `PERRY_FACTS_CANONICAL_NUMBERS=0` restores the decode.
if crate::expr::facts_canonical::enabled() {
assert!(
!body.contains(crate::nanbox::INT32_TAG_I64),
"a proven parameter takes its argument's bits at entry:\n{body}"
);
} else {
assert!(
body.contains(crate::nanbox::INT32_TAG_I64),
"a proven parameter converts an INT32 box at entry:\n{body}"
);
}
}

#[test]
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 @@ -240,6 +240,10 @@ pub struct ElisionFacts {
/// Method call byte offset → the call is a field element read of its
/// receiver (wpfacts `ModuleBindings::field_read_calls`, b1).
pub field_read_calls: std::collections::BTreeMap<u32, FactsFieldReadCall>,
/// Element reads (by `element_offset`) whose value the facts prove a
/// plain Number; `true` when it may also be `undefined` (wpfacts
/// `canonical_numbers`, P3 B1).
pub number_element_reads: std::collections::BTreeMap<u32, bool>,
pub indexed_metadata_pristine: bool,
pub numeric_functions: std::collections::BTreeMap<u32, FactsNumericFunction>,
/// Class id → its own statics that hold their definition for the
Expand Down Expand Up @@ -296,6 +300,7 @@ impl ElisionFacts {
&& self.number_calls.is_empty()
&& self.element_reads.is_empty()
&& self.field_read_calls.is_empty()
&& self.number_element_reads.is_empty()
&& self.locals.is_empty()
&& self.this_of.is_empty()
&& self.local_bodies.is_empty()
Expand Down
46 changes: 44 additions & 2 deletions crates/perry-codegen/src/expr/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,18 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String>
}
let mut leaves = Vec::new();
add_tree_leaves(expr, &mut leaves);
let facts: Vec<bool> = leaves
.iter()
.map(|leaf| facts_number_leaf(ctx, leaf))
.collect();
let needs_test: Vec<bool> = leaves
.iter()
.map(|leaf| !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf))
.zip(&facts)
.map(|(leaf, f)| !f && !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf))
.collect();

with_operands_rooted(ctx, &leaves, |ctx, values| {
witness_facts_leaves(ctx, values, &facts)?;
let mut cond: Option<String> = None;
for (value, is_tested) in values.iter().zip(needs_test.iter()) {
if !is_tested {
Expand Down Expand Up @@ -235,6 +241,36 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String>
})
}

/// P3 B1, C2 (`facts_canonical`): a guarded arithmetic leaf the facts prove a
/// plain Number needs no "is a Number" test.
fn facts_number_leaf(ctx: &FnCtx<'_>, leaf: &Expr) -> bool {
let proven = crate::expr::facts_canonical::of(ctx, leaf)
== Some(crate::expr::facts_canonical::Num::Number);
if proven {
crate::verify_facts::consumed(
crate::verify_facts::Fact::CanonicalNumber,
|| serde_json::json!({ "arith": "leaf" }),
);
crate::facts_consumed::used();
}
proven
}

/// Check mode: each fact-trusted leaf is a plain double.
fn witness_facts_leaves(ctx: &mut FnCtx<'_>, values: &[String], facts: &[bool]) -> Result<()> {
for (value, f) in values.iter().zip(facts) {
if *f {
crate::expr::facts_canonical::witness(
ctx,
value,
crate::expr::facts_canonical::Num::Number,
"arithmetic leaf",
)?;
}
}
Ok(())
}

/// The `+`-shaped guard for the arithmetic operators that cannot concatenate.
///
/// `-`, `*` and `/` apply ToNumeric to both operands, so unlike `+` there is
Expand Down Expand Up @@ -262,13 +298,19 @@ fn lower_guarded_numeric_arith(
fname: &str,
) -> Result<String> {
let leaves = [left, right];
let facts: Vec<bool> = leaves
.iter()
.map(|leaf| facts_number_leaf(ctx, leaf))
.collect();
let needs_test: Vec<bool> = leaves
.iter()
.map(|leaf| !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf))
.zip(&facts)
.map(|(leaf, f)| !f && !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, leaf))
.collect();
let range_guard = is_bitwise_op(op) && needs_test.contains(&true);

with_operands_rooted(ctx, &leaves, |ctx, values| {
witness_facts_leaves(ctx, values, &facts)?;
let mut cond: Option<String> = None;
for (value, is_tested) in values.iter().zip(needs_test.iter()) {
let is_num = if range_guard {
Expand Down
87 changes: 87 additions & 0 deletions crates/perry-codegen/src/expr/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,87 @@ fn lower_string_strict_eq_inline(
/// A compare operand that is statically a number (not a BigInt, not a value
/// that may come back boxed from a raw-f64 fallback): two of them take the
/// bare `fcmp`.
/// P3 B1, C1 (DESIGN-p3 rev 3.1 §2.3): a compare whose operands are both
/// proven plain Numbers, or Number-or-undefined, at least one of them by the
/// facts (`facts_canonical`). There is no INT32 prefix and no runtime helper:
///
/// - relational: the ordered `fcmp` (`undefined` is a NaN pattern, so every
/// comparison with it is false, as in JS);
/// - `===` with at least one plain Number: `fcmp oeq` (`une` for `!==`);
/// - `===` of two Number-or-undefined: `fcmp oeq || (bits equal and
/// undefined)`, and `!==` is its exact negation (REVIEW-p3 D1).
///
/// Check mode tests each fact-trusted operand (`facts_canonical::witness`).
fn lower_facts_number_compare(
ctx: &mut FnCtx<'_>,
op: CompareOp,
left: &Expr,
right: &Expr,
l: &str,
r: &str,
) -> Result<Option<String>> {
use super::facts_canonical::{self, Num};
if matches!(op, CompareOp::LooseEq | CompareOp::LooseNe) {
return Ok(None);
}
let fl = facts_canonical::of(ctx, left);
let fr = facts_canonical::of(ctx, right);
if fl.is_none() && fr.is_none() {
return Ok(None);
}
let side = |ctx: &FnCtx<'_>, e: &Expr, f: Option<Num>| {
f.or_else(|| compare_operand_is_numeric(ctx, e).then_some(Num::Number))
};
let (Some(kl), Some(kr)) = (side(ctx, left, fl), side(ctx, right, fr)) else {
return Ok(None);
};
crate::verify_facts::consumed(
crate::verify_facts::Fact::CanonicalNumber,
|| serde_json::json!({ "compare": format!("{op:?}") }),
);
crate::facts_consumed::used();
if let Some(f) = fl {
facts_canonical::witness(ctx, l, f, "compare operand")?;
}
if let Some(f) = fr {
facts_canonical::witness(ctx, r, f, "compare operand")?;
}
let blk = ctx.block();
let bit = match op {
CompareOp::Lt => blk.fcmp("olt", l, r),
CompareOp::Le => blk.fcmp("ole", l, r),
CompareOp::Gt => blk.fcmp("ogt", l, r),
CompareOp::Ge => blk.fcmp("oge", l, r),
CompareOp::Eq | CompareOp::Ne
if kl == Num::NumberOrUndefined && kr == Num::NumberOrUndefined =>
{
let oeq = blk.fcmp("oeq", l, r);
let lb = blk.bitcast_double_to_i64(l);
let rb = blk.bitcast_double_to_i64(r);
let same = blk.icmp_eq(I64, &lb, &rb);
let undef = blk.icmp_eq(I64, &lb, crate::nanbox::TAG_UNDEFINED_I64);
let both_undef = blk.and(I1, &same, &undef);
let eq = blk.or(I1, &oeq, &both_undef);
if matches!(op, CompareOp::Ne) {
blk.xor(I1, &eq, "true")
} else {
eq
}
}
CompareOp::Eq => blk.fcmp("oeq", l, r),
CompareOp::Ne => blk.fcmp("une", l, r),
CompareOp::LooseEq | CompareOp::LooseNe => unreachable!(),
};
let tagged = blk.select(
I1,
&bit,
I64,
crate::nanbox::TAG_TRUE_I64,
crate::nanbox::TAG_FALSE_I64,
);
Ok(Some(blk.bitcast_i64_to_double(&tagged)))
}

fn compare_operand_is_numeric(ctx: &FnCtx<'_>, operand: &Expr) -> bool {
is_numeric_expr(ctx, operand)
&& !expr_may_return_boxed_value_from_raw_f64_fallback(ctx, operand)
Expand Down Expand Up @@ -1893,6 +1974,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
return Ok(blk.bitcast_i64_to_double(&result_bits));
}

// P3 B1, C1 (`facts_canonical`): both operands proven plain
// Numbers (or Number-or-undefined), at least one by the facts.
if let Some(v) = lower_facts_number_compare(ctx, *op, left, right, &l, &r)? {
return Ok(v);
}

// An ordered relational compare (`<`, `<=`, `>`, `>=`) whose
// operands aren't BOTH statically numeric needs the full ECMAScript
// Abstract Relational Comparison: ToPrimitive (`{valueOf}`/`Date`),
Expand Down
127 changes: 127 additions & 0 deletions crates/perry-codegen/src/expr/facts_canonical.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! P3 B1 (DESIGN-p3-canonical-numbers rev 3.1 §2): consumers that drop an
//! INT32 test because the facts prove a value a plain Number.
//!
//! The fact is wpfacts' NUM declaration (REVIEW-p3 R2): an element read whose
//! value node holds only Number at every copy
//! (`ElisionFacts::number_element_reads`), possibly with `undefined` for a
//! hole or an index past the end. Every Number a node can hold is a program
//! Number (codegen lowers literals and arithmetic to plain doubles) or comes
//! from a builtin whose NUM seed is audited against its runtime function
//! (`perry-wpfacts/NUM_SEEDS.md`). So such a value is a plain IEEE double and
//! never an INT32-boxed word.
//!
//! The consumers:
//! - C1 (`expr/compare.rs`): a compare whose operands are both proven lowers
//! to `fcmp`, with no INT32 prefix. Two Number-or-undefined operands keep
//! an identity arm for `undefined === undefined` (REVIEW-p3 D1).
//! - C2 (`expr/binary.rs`): a guarded arithmetic leaf proven a Number needs
//! no "is a Number" test.
//!
//! Check mode (`PERRY_VERIFY_FACTS=1`): every operand a consumer trusts is
//! tested "is a plain double" (`emit_plain_number_test`: outside the tag band
//! and not HANDLE_TAG; REVIEW-p3 R7), or is exactly `undefined` when the
//! fact allows it. A miss exits 86 (`Fact::CanonicalNumber`).
//!
//! `PERRY_FACTS_CANONICAL_NUMBERS=0` turns these consumers off (wpfacts then
//! exports no element values either).

use anyhow::Result;
use perry_hir::{Expr, LogicalOp};

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

/// What the facts prove about a value.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Num {
/// A plain double.
Number,
/// A plain double or `undefined`.
NumberOrUndefined,
}

pub(crate) fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
!matches!(
std::env::var("PERRY_FACTS_CANONICAL_NUMBERS").as_deref(),
Ok("0") | Ok("off") | Ok("false")
)
})
}

/// The facts' answer for `e`, when they cover it.
pub(crate) fn of(ctx: &FnCtx<'_>, e: &Expr) -> Option<Num> {
if !enabled() || ctx.wpfacts_off || ctx.wpfacts.interp_guard {
return None;
}
match e {
Expr::IndexGet { element_offset, .. } if *element_offset != 0 => ctx
.wpfacts
.number_element_reads
.get(element_offset)
.map(|&undef| {
if undef {
Num::NumberOrUndefined
} else {
Num::Number
}
}),
// `x ?? d`: `d` replaces exactly the `undefined` (and `null`, which a
// Number-or-undefined value never is).
Expr::Logical {
op: LogicalOp::Coalesce,
left,
right,
} => {
of(ctx, left)?;
let d = matches!(right.as_ref(), Expr::Integer(_) | Expr::Number(_))
|| of(ctx, right) == Some(Num::Number);
d.then_some(Num::Number)
}
_ => None,
}
}

/// Check mode: the trusted `value` (the lowering of an operand `of` proved
/// `fact`) is a plain double, or `undefined` where the fact allows it.
pub(crate) fn witness(ctx: &mut FnCtx<'_>, value: &str, fact: Num, what: &str) -> Result<()> {
if !crate::verify_facts::enabled() {
return Ok(());
}
crate::verify_facts::covered(
crate::verify_facts::Fact::CanonicalNumber,
|| serde_json::json!({ "site": what }),
);
let blk = ctx.block();
let plain = crate::codegen::emit_plain_number_test(blk, value);
let ok = if fact == Num::NumberOrUndefined {
let bits = blk.bitcast_double_to_i64(value);
let undef = blk.icmp_eq(I64, &bits, crate::nanbox::TAG_UNDEFINED_I64);
blk.or(I1, &plain, &undef)
} else {
plain
};
crate::verify_facts::tick(ctx);
let miss = ctx.new_block("canonical_number.verify_miss");
let next = ctx.new_block("canonical_number.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 site_arg = crate::verify_facts::site_arg(
ctx,
&format!("canonical number {what}"),
crate::verify_facts::Fact::CanonicalNumber,
&[],
);
let _ = crate::verify_facts::check(
ctx,
I32,
"perry_wpfacts_check_miss",
&[(DOUBLE, value)],
&site_arg,
);
ctx.block().unreachable();
ctx.current_block = next;
Ok(())
}
1 change: 1 addition & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ mod channel;
#[cfg(test)]
mod class_method_arguments_object_tests;
pub(crate) mod collection_receiver;
pub(crate) mod facts_canonical;
pub(crate) mod facts_number;
mod helpers;
mod i32_fast_path;
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/verify_facts/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) enum Fact {
ProtoAdd,
ElementRead,
FieldReadCall,
CanonicalNumber,
}
impl Fact {
pub(super) fn name(self) -> &'static str {
Expand Down Expand Up @@ -75,6 +76,7 @@ impl Fact {
Self::ProtoAdd => "prototype add",
Self::ElementRead => "proven element read",
Self::FieldReadCall => "field read call",
Self::CanonicalNumber => "canonical number",
}
}
}
Expand Down
Loading
Loading