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
34 changes: 34 additions & 0 deletions changelog.d/8167-spec-clone-self-recursion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
### Performance

- A specialized `$spec_*` entry can now re-enter **itself**. The only raw-`i32`
argument shapes a call site could prove were an `i32` literal and a bare
`LocalGet` of an integer local, and a recursive call's argument is almost
always derived (`fib(n - 1)`), so every recursive edge inside a clone
targeted the generic public symbol. The clone therefore ran exactly once per
top-level call and the whole recursion paid dynamic dispatch — on `fib(40)`
that was one fast call out of ~331 million. Compiling
`function fib(n: number): number { return n < 2 ? n : fib(n-1) + fib(n-2); }`
now retires **4.68 G instructions instead of 227.85 G** (48.7x; 13.76 s →
0.82 s user, same host, compiler-only A/B against `48935af78`).

The new proof composes the leaf fact the entry already owns. A parameter the
entry binds as a raw LLVM `i32` is finite, integral and not `-0`, and integer
literals and `+`/`-` over such leaves preserve all three while magnitudes stay
under 2^53. The one remaining obligation from the slot contract
(`js_typed_i32_arg_guard` in `perry-runtime/src/native_abi.rs`) is 32-bit
containment, and that is precisely where assuming "it is an integer, ship it"
would be wrong: `n - 1` for an i32 `n` is `[-2^31 - 1, 2^31 - 2]`, one value
wider than the slot. A window inside the slot is called directly, a window
that merely overlaps takes one range test with the permanent boxed entry as
the cold arm, and a window with no overlap keeps the boxed path with no
diamond emitted.

Multiplication is deliberately outside the derivation, and that is measured
rather than argued: `n * 0` with `n < 0` is `-0`, which the guard rejects on
purpose because the raw slot has no `-0` to round-trip through. Admitting
`Mul` makes
`function probe(n: number): number { if (n === -5) return probe(n * 0); return 1 / n; }`
print `Infinity` where node `v26.5.1` prints `-Infinity`.

Mutual recursion (`f → g → f`) and higher-order calls are out of scope: the
leaf fact does not cross a function boundary, and nothing here changes that.
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ pub(super) fn compile_closure(
spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
spec_i32_params: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
index_used_locals: native_facts.index_used_locals(),
strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,7 @@ pub(super) fn compile_module_entry(
spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
spec_i32_params: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
index_used_locals: main_native_facts.index_used_locals(),
strictly_i32_bounded_locals: main_native_facts.strictly_i32_bounded_locals(),
Expand Down Expand Up @@ -1539,6 +1540,7 @@ pub(super) fn compile_module_entry(
spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
spec_i32_params: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
index_used_locals: init_native_facts.index_used_locals(),
strictly_i32_bounded_locals: init_native_facts.strictly_i32_bounded_locals(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,7 @@ pub(super) fn compile_function(
spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
spec_i32_params: spec_i32_params.clone(),
i1_local_slots: HashMap::new(),
index_used_locals: native_facts.index_used_locals(),
strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ pub(super) fn compile_method(
spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
spec_i32_params: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
index_used_locals: native_facts.index_used_locals(),
strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(),
Expand Down Expand Up @@ -1615,6 +1616,7 @@ pub(super) fn compile_static_method(
spec_return_proofs: &cross_module.spec_return_proofs,
spec_ta_bindings: &cross_module.spec_ta_bindings,
spec_ta_ready: std::collections::HashSet::new(),
spec_i32_params: std::collections::HashSet::new(),
i1_local_slots: HashMap::new(),
index_used_locals: native_facts.index_used_locals(),
strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ mod ordinary_param_guard_tests;
mod param_guard;
mod spec_abi;
mod spec_return_proof;
#[cfg(test)]
mod spec_self_recursion_tests;
mod string_pool;
#[cfg(test)]
mod testing_feature_gate_tests;
Expand Down
3 changes: 2 additions & 1 deletion crates/perry-codegen/src/codegen/spec_abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,12 @@ mod tests {
#[test]
fn spec_abi_symbol_reachability() {
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let allowed: [&str; 5] = [
let allowed: [&str; 6] = [
"codegen/spec_abi.rs", // naming + this test
"codegen/function.rs", // entry emission
"codegen/mod.rs", // eligibility/budget loop
"codegen/ordinary_param_guard_tests.rs", // structural assertion only
"codegen/spec_self_recursion_tests.rs", // structural assertion only
"lower_call/func_ref.rs", // direct-call dispatch
];
let mut offenders: Vec<String> = Vec::new();
Expand Down
230 changes: 230 additions & 0 deletions crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
//! #8167 — a specialized entry must be able to re-enter ITSELF.
//!
//! Before this, the only raw-`i32` argument shapes a call site could prove
//! were an `i32` literal and a bare `LocalGet` of an integer local. A
//! recursive call almost never has that shape — its argument is DERIVED
//! (`fib(n - 1)`) — so every recursive edge inside a `$spec_i32` clone
//! targeted the generic public symbol. The clone therefore ran once per
//! top-level call and the whole recursion paid dynamic dispatch.
//!
//! These fixtures pin both directions: the derived argument reaches the
//! clone, and an argument whose value the slot contract does NOT admit still
//! reaches the boxed entry.

use crate::{compile_module, CompileOptions};
use perry_hir::types::Type;
use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module, Param, Stmt};

fn function_ir<'a>(ir: &'a str, marker: &str) -> &'a str {
let start = ir
.match_indices("define ")
.find(|(index, _)| {
let line_end = ir[*index..]
.find('\n')
.map(|offset| index + offset)
.unwrap_or(ir.len());
ir[*index..line_end].contains(marker)
})
.map(|(index, _)| index)
.unwrap_or_else(|| panic!("missing function containing {marker}:\n{ir}"));
let end = ir[start..]
.find("\n}")
.map(|offset| start + offset)
.expect("function terminator");
&ir[start..end]
}

/// `function f(n: number): number { return n < 2 ? n : f(<lhs>) + f(<rhs>); }`
/// plus a module-init `f(40)` — the literal site is what makes the plan a
/// raw-`i32` tuple in the first place.
fn recursive_module(lhs: Expr, rhs: Expr) -> Module {
let f = Function {
id: 1,
name: "f".to_string(),
type_params: Vec::new(),
params: vec![Param {
id: 10,
name: "n".to_string(),
ty: Type::Number,
default: None,
decorators: Vec::new(),
is_rest: false,
arguments_object: None,
}],
return_type: Type::Number,
body: vec![Stmt::Return(Some(Expr::Conditional {
condition: Box::new(Expr::Compare {
op: CompareOp::Lt,
left: Box::new(Expr::LocalGet(10)),
right: Box::new(Expr::Integer(2)),
}),
then_expr: Box::new(Expr::LocalGet(10)),
else_expr: Box::new(Expr::Binary {
op: BinaryOp::Add,
left: Box::new(Expr::Call {
callee: Box::new(Expr::FuncRef(1)),
args: vec![lhs],
type_args: Vec::new(),
byte_offset: 0,
}),
right: Box::new(Expr::Call {
callee: Box::new(Expr::FuncRef(1)),
args: vec![rhs],
type_args: Vec::new(),
byte_offset: 0,
}),
}),
}))],
is_async: false,
is_generator: false,
is_strict: true,
is_exported: false,
captures: Vec::new(),
decorators: Vec::new(),
was_plain_async: false,
was_unrolled: false,
};
let mut module = Module::new("spec_self_recursion.ts");
module.functions.push(f);
module.init.push(Stmt::Expr(Expr::Call {
callee: Box::new(Expr::FuncRef(1)),
args: vec![Expr::Integer(40)],
type_args: Vec::new(),
byte_offset: 0,
}));
module
}

fn compile_ir(module: &Module) -> String {
let opts = CompileOptions {
emit_ir_only: true,
output_type: "executable".to_string(),
..Default::default()
};
String::from_utf8(compile_module(module, opts).expect("module compiles"))
.expect("LLVM IR is UTF-8")
}

fn arith(op: BinaryOp, right: i64) -> Expr {
Expr::Binary {
op,
left: Box::new(Expr::LocalGet(10)),
right: Box::new(Expr::Integer(right)),
}
}

#[test]
fn derived_recursive_i32_argument_re_enters_the_clone_behind_a_range_test() {
let ir = compile_ir(&recursive_module(
arith(BinaryOp::Sub, 1),
arith(BinaryOp::Sub, 2),
));
let clone = function_ir(&ir, "$spec_i32(");

// The subject has to exist before any of this means anything: the literal
// module-init site must have produced a raw-i32 clone.
assert!(
clone
.starts_with("define internal double @perry_fn_spec_self_recursion_ts__f$spec_i32(i32"),
"expected a raw-i32 clone to specialize:\n{clone}"
);

// BOTH recursive edges re-enter the clone.
assert_eq!(
clone
.matches("call double @perry_fn_spec_self_recursion_ts__f$spec_i32(i32")
.count(),
2,
"both recursive calls must target the clone:\n{clone}"
);

// `n - 1` for an i32 `n` is [-2^31 - 1, 2^31 - 2] — one value wider than
// the slot — so each edge is guarded by a 32-bit range test, and the
// always-correct boxed entry is still the cold arm.
assert_eq!(
clone.matches("fcmp oge double").count(),
2,
"each derived argument needs its own low-bound test:\n{clone}"
);
assert!(clone.contains("-2147483648.0"));
assert!(clone.contains("2147483647.0"));
assert_eq!(
clone
.matches("call double @perry_fn_spec_self_recursion_ts__f(double")
.count(),
2,
"the out-of-range arm must still reach the permanent boxed ABI:\n{clone}"
);
}

#[test]
fn a_multiplied_recursive_argument_keeps_the_boxed_call() {
// `n * 0` with `n < 0` is `-0`, which `js_typed_i32_arg_guard` rejects on
// purpose (the raw slot has no `-0` to round-trip through), and a product
// of two i32s leaves the exact-integer window. Multiplication is therefore
// outside the derivation, and this call must NOT be routed.
let ir = compile_ir(&recursive_module(
arith(BinaryOp::Mul, 2),
arith(BinaryOp::Sub, 1),
));
let clone = function_ir(&ir, "$spec_i32(");

assert!(
clone
.starts_with("define internal double @perry_fn_spec_self_recursion_ts__f$spec_i32(i32"),
"the clone must still exist, or this asserts nothing:\n{clone}"
);
// The `n - 1` edge proves — so the fixture is live — and the `n * 2` edge
// does not.
assert_eq!(
clone
.matches("call double @perry_fn_spec_self_recursion_ts__f$spec_i32(i32")
.count(),
1,
"only the subtracting edge may reach the clone:\n{clone}"
);
}

#[test]
fn an_unproven_local_recursive_argument_keeps_the_boxed_call() {
// A parameter the specialized entry did NOT bind as a raw i32 carries no
// leaf fact, so nothing derived from it can be proven either.
let ir = compile_ir(&recursive_module(
Expr::Binary {
op: BinaryOp::Sub,
left: Box::new(Expr::Call {
callee: Box::new(Expr::FuncRef(1)),
args: vec![Expr::Integer(3)],
type_args: Vec::new(),
byte_offset: 0,
}),
right: Box::new(Expr::Integer(1)),
},
arith(BinaryOp::Sub, 2),
));
let clone = function_ir(&ir, "$spec_i32(");

assert!(
clone
.starts_with("define internal double @perry_fn_spec_self_recursion_ts__f$spec_i32(i32"),
"the clone must still exist, or this asserts nothing:\n{clone}"
);
// `f(3)` is a literal site and reaches the clone directly; `f(3) - 1` is
// not a derivable leaf, so its enclosing call stays boxed. That is one
// clone call for the literal, one for the `n - 2` edge, and none for the
// call-result argument.
assert_eq!(
clone
.matches("call double @perry_fn_spec_self_recursion_ts__f$spec_i32(i32")
.count(),
2,
"a call-result argument must not be treated as an i32 leaf:\n{clone}"
);
assert_eq!(
clone
.matches("call double @perry_fn_spec_self_recursion_ts__f(double")
.count(),
2,
"the unprovable edge plus the in-range arm's fallback:\n{clone}"
);
}
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,15 @@ pub(crate) struct FnCtx<'a> {
/// sites it dominates; closure bodies get their own (empty) set.
pub spec_ta_ready: std::collections::HashSet<u32>,

/// Parameters of THIS body that the specialized entry binds as a raw
/// LLVM `i32` (`SpecParamRep::I32`). Their JS value is an exact integer
/// inside the signed 32-bit range by calling convention — never
/// fractional, never `-0`, never NaN — which is the leaf fact
/// `lower_call/func_ref.rs` composes into a raw-`i32` argument proof for
/// a (typically self-recursive) call back into the same entry. Empty in
/// the generic body, in module init, and in every closure.
pub spec_i32_params: std::collections::HashSet<u32>,

/// Parallel `i1` slots for ordinary boolean locals that have stayed inside
/// the representation-first subset. The generic `double` slot remains as a
/// compatibility shadow for existing lowering paths, but typed consumers
Expand Down
Loading
Loading