diff --git a/changelog.d/8290-tier-b-self-recursion.md b/changelog.d/8290-tier-b-self-recursion.md new file mode 100644 index 0000000000..1a0a66249c --- /dev/null +++ b/changelog.d/8290-tier-b-self-recursion.md @@ -0,0 +1,7 @@ +### Performance + +- Guarded `$spec_b` clones now call themselves directly when their recursive + arguments constructively produce Numbers, avoiding the public parameter + guard on every recursive edge. The proof is limited to canonical numeric + constructions, so BigInt-capable arithmetic and annotation-only claims keep + the guarded fallback. diff --git a/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs b/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs index a302f255c0..f1d7b59f94 100644 --- a/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs +++ b/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs @@ -95,6 +95,99 @@ fn recursive_module(lhs: Expr, rhs: Expr) -> Module { module } +/// The same recursive body reached through an ordinary `number` parameter +/// guard. The `Number(...)` construction gives the outer call a runtime +/// Number proof without creating a viable raw-i32 tuple, so the emitted clone +/// is the boxed `$spec_b` shape whose recursive routing #8169 exercises. +fn guarded_recursive_module(lhs: Expr, rhs: Expr) -> Module { + let mut module = recursive_module(lhs, rhs); + module.init.clear(); + module.init.push(Stmt::Let { + id: 20, + name: "k".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::NumberCoerce(Box::new(Expr::Undefined))), + }); + module.init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::LocalGet(20)], + type_args: Vec::new(), + byte_offset: 0, + })); + module +} + +/// A guarded Number parameter plus an unconstrained value whose arithmetic +/// may produce a BigInt. The recursive first argument must keep the public +/// guard: when `x` is a BigInt, `x * x` is a BigInt too. +fn bigint_capable_guarded_recursive_module() -> 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, + }, + Param { + id: 11, + name: "x".to_string(), + ty: Type::Any, + 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(1)), + }), + then_expr: Box::new(Expr::LocalGet(10)), + else_expr: Box::new(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![ + Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::LocalGet(11)), + right: Box::new(Expr::LocalGet(11)), + }, + Expr::LocalGet(11), + ], + 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_bigint.ts"); + module.functions.push(f); + module.init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::Undefined, Expr::Undefined], + type_args: Vec::new(), + byte_offset: 0, + })); + module +} + fn compile_ir(module: &Module) -> String { let opts = CompileOptions { emit_ir_only: true, @@ -234,3 +327,73 @@ fn an_unproven_local_recursive_argument_keeps_the_boxed_call() { "the unprovable edge plus the in-range arm's fallback:\n{clone}" ); } + +#[test] +fn derived_recursive_number_argument_re_enters_the_guarded_clone() { + let ir = compile_ir(&guarded_recursive_module( + arith(BinaryOp::Sub, 1), + arith(BinaryOp::Sub, 2), + )); + let public = function_ir(&ir, "@perry_fn_spec_self_recursion_ts__f("); + let clone = function_ir(&ir, "$spec_b("); + + // Keep both halves of the subject live: this must be the ordinary boxed + // clone selected by the public Number guard, not the raw-i32 Tier-A path. + assert!(public.contains("call i32 @js_typed_f64_arg_guard(")); + assert!( + clone.starts_with("define internal") + && clone.contains("double @perry_fn_spec_self_recursion_ts__f$spec_b(double"), + "expected a guarded boxed clone to specialize:\n{clone}" + ); + + // #8203 gives recursion-participating clones `preserve_nonecc`, which lands + // between `call` and the return type, so match the call LINE rather than a + // fixed prefix. + assert_eq!( + clone + .lines() + .filter(|l| l.contains("call") + && l.contains("@perry_fn_spec_self_recursion_ts__f$spec_b(double")) + .count(), + 2, + "both derived Number arguments must re-enter the guarded clone directly:\n{clone}" + ); + assert_eq!( + clone + .lines() + .filter( + |l| l.contains("call") && l.contains("@perry_fn_spec_self_recursion_ts__f(double") + ) + .count(), + 0, + "a constructively numeric recursive argument must not re-run the public guard:\n{clone}" + ); +} + +#[test] +fn bigint_capable_recursive_argument_keeps_the_public_guard() { + let ir = compile_ir(&bigint_capable_guarded_recursive_module()); + let public = function_ir(&ir, "@perry_fn_spec_self_recursion_bigint_ts__f("); + let clone = function_ir(&ir, "$spec_b_b("); + + assert!(public.contains("call i32 @js_typed_f64_arg_guard(")); + assert!( + clone.starts_with("define internal") + && clone.contains("double @perry_fn_spec_self_recursion_bigint_ts__f$spec_b_b(double"), + "expected a guarded boxed clone to specialize:\n{clone}" + ); + assert_eq!( + clone + .matches("call double @perry_fn_spec_self_recursion_bigint_ts__f$spec_b_b(double") + .count(), + 0, + "BigInt-capable arithmetic must not bypass the Number guard:\n{clone}" + ); + assert_eq!( + clone + .matches("call double @perry_fn_spec_self_recursion_bigint_ts__f(double") + .count(), + 1, + "the unproven recursive edge must retain the public guarded ABI:\n{clone}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs index f914c21323..160b8d8f24 100644 --- a/crates/perry-codegen/src/lower_call/func_ref.rs +++ b/crates/perry-codegen/src/lower_call/func_ref.rs @@ -681,6 +681,18 @@ pub(crate) fn guarded_path_type(ctx: &FnCtx<'_>, expr: &Expr) -> Option Some(Type::Null), Expr::Undefined | Expr::Void(_) => Some(Type::Void), Expr::Call { .. } => guarded_call_return_proof(ctx, expr), + // #8169: a Tier-B clone's entry guard gives its boxed Number + // parameters real runtime proofs, and arithmetic derived from those + // parameters constructs another Number. Let a recursive `f(n - 1)` + // therefore re-enter `$spec_b` instead of paying the public guard on + // every edge. + // + // Use the canonical-value predicate rather than `is_numeric_expr` + // alone. The latter deliberately admits some dynamic/BigInt-capable + // arithmetic for lowering decisions; this proof is used to BYPASS a + // runtime type guard, so it must exclude values that can still be a + // boxed BigInt or arise only from an unenforced annotation. + _ if crate::type_analysis::expr_produces_canonical_raw_f64(ctx, expr) => Some(Type::Number), _ => None, } }