From 8e38d28b562c064817fd6d8ca7878ea923784d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 22:56:00 +0200 Subject: [PATCH 1/5] Optimize erased numeric predicates with typed clones --- changelog.d/9919-typed-numeric-predicate.md | 4 + crates/perry-codegen/src/codegen/function.rs | 19 +++- crates/perry-codegen/src/codegen/mod.rs | 2 +- crates/perry-codegen/src/codegen/typed_abi.rs | 106 +++++++++++++++++- .../tests/native_proof_regressions.rs | 99 ++++++++++++++++ 5 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 changelog.d/9919-typed-numeric-predicate.md diff --git a/changelog.d/9919-typed-numeric-predicate.md b/changelog.d/9919-typed-numeric-predicate.md new file mode 100644 index 0000000000..63b079a579 --- /dev/null +++ b/changelog.d/9919-typed-numeric-predicate.md @@ -0,0 +1,4 @@ +### Changed + +- Boolean helpers with erased numeric parameters now use a guarded typed clone + for comparison-heavy predicates, avoiding repeated generic relational calls. diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 21eb811fb3..80eafc46c3 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -25,9 +25,10 @@ use super::spec_abi::{ }; use super::typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_function_body_name, lower_typed_f64_body, - lower_typed_i1_body, lower_typed_i32_body, lower_typed_string_body, typed_f64_function_name, - typed_i1_function_name, typed_i32_function_name, typed_param_reps_for_params, - typed_string_function_name, TypedFunctionTrampolineKind, TypedParamRep, + lower_typed_i1_body_with_seed_locals, lower_typed_i32_body, lower_typed_string_body, + typed_f64_function_name, typed_i1_function_name, typed_i1_function_param_reps, + typed_i32_function_name, typed_param_reps_for_params, typed_string_function_name, + TypedFunctionTrampolineKind, TypedParamRep, }; /// Internal body name for a self-recursive allocator whose arena-state pointer @@ -144,7 +145,7 @@ pub(super) fn compile_typed_i1_function( .cloned() .ok_or_else(|| anyhow!("function name not resolved for {}", f.name))?; let llvm_name = typed_i1_function_name(&generic_name); - let param_reps = typed_param_reps_for_params(&f.params) + let param_reps = typed_i1_function_param_reps(f) .ok_or_else(|| anyhow!("typed-i1 function '{}' has unsupported parameter", f.name))?; let params: Vec<(LlvmType, String)> = f .params @@ -159,7 +160,13 @@ pub(super) fn compile_typed_i1_function( let value = { let blk = lf.block_mut(0).unwrap(); - lower_typed_i1_body(blk, &f.params, &f.body)? + let seed_reps = f + .params + .iter() + .zip(param_reps.iter().copied()) + .map(|(param, rep)| (param.id, rep)) + .collect(); + lower_typed_i1_body_with_seed_locals(blk, &f.params, &f.body, HashMap::new(), seed_reps)? }; lf.block_mut(0).unwrap().ret(I1, &value); Ok(()) @@ -288,7 +295,7 @@ fn emit_public_typed_function_trampoline( .unwrap_or_else(|| vec![TypedParamRep::F64; f.params.len()]), TypedFunctionTrampolineKind::I32 => typed_param_reps_for_params(&f.params) .unwrap_or_else(|| vec![TypedParamRep::I32; f.params.len()]), - TypedFunctionTrampolineKind::I1 => typed_param_reps_for_params(&f.params) + TypedFunctionTrampolineKind::I1 => typed_i1_function_param_reps(f) .unwrap_or_else(|| vec![TypedParamRep::I1; f.params.len()]), TypedFunctionTrampolineKind::StringRef => typed_param_reps_for_params(&f.params) .unwrap_or_else(|| vec![TypedParamRep::StringRef; f.params.len()]), diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index d3d84cb5a1..4b3c0ec455 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1831,7 +1831,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> match typed_abi::typed_i1_function_rejection_reason(f) { None => { typed_i1_functions.insert(f.id); - if let Some(reps) = typed_abi::typed_param_reps_for_params(&f.params) { + if let Some(reps) = typed_abi::typed_i1_function_param_reps(f) { typed_i1_function_param_reps.insert(f.id, reps); } } diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index a9153644f4..3479105751 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -164,6 +164,22 @@ pub(crate) fn typed_param_reps_for_params( .collect() } +/// Parameter representations for a Boolean-returning top-level function. +/// +/// In addition to declared scalar types, admit erased (`Any`/`Unknown`) +/// parameters when the complete straight-line body proves that they are used +/// as numbers. The public JSValue entry guards every inferred F64 parameter +/// once and retains the unchanged generic body for guard failure. Four +/// comparisons is the deliberately small profitability floor: below it the +/// entry guard and duplicate body can cost as much as the generic relational +/// fast paths they replace. +pub(crate) fn typed_i1_function_param_reps(function: &Function) -> Option> { + if typed_i1_function_rejection_reason_impl(function).is_none() { + return typed_param_reps_for_params(&function.params); + } + erased_numeric_predicate_param_reps(function) +} + pub(crate) fn typed_f64_closure_capture_reps( expr: &Expr, module_local_types: &HashMap, @@ -782,7 +798,7 @@ pub(crate) fn is_typed_i32_function_candidate(function: &Function) -> bool { #[allow(dead_code)] pub(crate) fn is_typed_i1_function_candidate(function: &Function) -> bool { - typed_i1_function_rejection_reason_impl(function).is_none() + typed_i1_function_rejection_reason(function).is_none() } #[allow(dead_code)] @@ -820,7 +836,14 @@ pub(crate) fn typed_i32_function_rejection_reason( pub(crate) fn typed_i1_function_rejection_reason( function: &Function, ) -> Option { - typed_i1_function_rejection_reason_impl(function) + let declared_reason = typed_i1_function_rejection_reason_impl(function); + if matches!(declared_reason, Some(TypedCloneRejectionReason::ParamNotI1)) + && erased_numeric_predicate_param_reps(function).is_some() + { + None + } else { + declared_reason + } } pub(crate) fn typed_string_function_rejection_reason( @@ -1163,6 +1186,85 @@ pub(crate) fn typed_string_closure_rejection_reason_with_types( typed_string_body_rejection_reason(body, locals) } +const ERASED_NUMERIC_PREDICATE_MIN_COMPARISONS: usize = 4; + +fn numeric_comparisons_in_typed_i1_expr( + expr: &Expr, + locals: &HashMap, +) -> usize { + match expr { + Expr::Compare { left, right, .. } + if expr_is_typed_f64_safe(left, locals) && expr_is_typed_f64_safe(right, locals) => + { + 1 + } + Expr::Logical { left, right, .. } => { + numeric_comparisons_in_typed_i1_expr(left, locals) + + numeric_comparisons_in_typed_i1_expr(right, locals) + } + Expr::Unary { operand, .. } => numeric_comparisons_in_typed_i1_expr(operand, locals), + _ => 0, + } +} + +fn erased_numeric_predicate_param_reps(function: &Function) -> Option> { + if function.is_async + || function.is_generator + || function.was_plain_async + || !function.captures.is_empty() + || !matches!(function.return_type, Type::Boolean) + || function.params.iter().any(|param| { + param.default.is_some() || param.is_rest || param.arguments_object.is_some() + }) + { + return None; + } + + let mut referenced = HashSet::new(); + crate::collectors::collect_ref_ids_in_stmts(&function.body, &mut referenced); + let mut inferred_erased = false; + let reps: Vec = function + .params + .iter() + .map(|param| { + if let Some(rep) = typed_param_rep_for_type(¶m.ty) { + return Some(rep); + } + if matches!(param.ty, Type::Any | Type::Unknown) && referenced.contains(¶m.id) { + inferred_erased = true; + return Some(TypedParamRep::F64); + } + None + }) + .collect::>()?; + if !inferred_erased { + return None; + } + + let locals: HashMap = function + .params + .iter() + .zip(reps.iter().copied()) + .map(|(param, rep)| (param.id, rep)) + .collect(); + if typed_i1_body_rejection_reason(&function.body, locals.clone()).is_some() { + return None; + } + let comparisons = function + .body + .iter() + .filter_map(|stmt| match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Return(Some(expr)) => Some(expr), + _ => None, + }) + .map(|expr| numeric_comparisons_in_typed_i1_expr(expr, &locals)) + .sum::(); + (comparisons >= ERASED_NUMERIC_PREDICATE_MIN_COMPARISONS).then_some(reps) +} + fn typed_i1_function_rejection_reason_impl( function: &Function, ) -> Option { diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index b14de19aa9..71f6b64c92 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -8730,6 +8730,60 @@ fn typed_i1_numeric_predicate_module() -> Module { } } +fn typed_i1_erased_numeric_predicate_module() -> Module { + let mut module = typed_i1_numeric_predicate_module(); + module.name = "typed_i1_erased_numeric_predicate.ts".to_string(); + + let range = |lo, hi| Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Compare { + op: CompareOp::Ge, + left: Box::new(local(1)), + right: Box::new(int(lo)), + }), + right: Box::new(Expr::Compare { + op: CompareOp::Le, + left: Box::new(local(1)), + right: Box::new(int(hi)), + }), + }; + let equal = |value| Expr::Compare { + op: CompareOp::Eq, + left: Box::new(local(1)), + right: Box::new(int(value)), + }; + let predicate = vec![ + range(0x1100, 0x115f), + equal(0x2329), + range(0x2e80, 0x303e), + equal(0xa015), + ] + .into_iter() + .reduce(|left, right| Expr::Logical { + op: LogicalOp::Or, + left: Box::new(left), + right: Box::new(right), + }) + .unwrap(); + + module.functions[0].name = "is_wide".to_string(); + module.functions[0].params = vec![param(1, "q", Type::Any)]; + module.functions[0].body = vec![Stmt::Return(Some(predicate))]; + + module.functions[1].name = "caller".to_string(); + module.functions[1].params = vec![param(3, "s", Type::String)]; + module.functions[1].body = vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::StringCodePointAt { + string: Box::new(local(3)), + index: Box::new(int(0)), + }], + type_args: Vec::new(), + byte_offset: 0, + }))]; + module +} + fn typed_i1_i32_predicate_module() -> Module { Module { name: "typed_i1_i32_predicate.ts".to_string(), @@ -11833,6 +11887,51 @@ fn typed_i1_numeric_predicate_function_uses_f64_params_and_public_wrapper() { ); } +#[test] +fn typed_i1_erased_numeric_predicate_guards_once_and_uses_f64_comparisons() { + let ir = String::from_utf8( + compile_module(&typed_i1_erased_numeric_predicate_module(), empty_opts()).unwrap(), + ) + .unwrap(); + let public = "perry_fn_typed_i1_erased_numeric_predicate_ts__is_wide"; + let typed = "perry_fn_typed_i1_erased_numeric_predicate_ts__is_wide$typed_i1"; + let generic_body = "perry_fn_typed_i1_erased_numeric_predicate_ts__is_wide$generic"; + let caller = "perry_fn_typed_i1_erased_numeric_predicate_ts__caller"; + let wrapper_ir = function_ir_section(&ir, public); + let typed_ir = defined_function_ir_section(&ir, typed); + let generic_ir = defined_function_ir_section(&ir, generic_body); + let caller_ir = body_ir_section(&ir, caller); + + assert!( + ir.contains(&format!("define internal i1 @{typed}(double %arg1)")), + "erased numeric predicate should have an f64 typed clone:\n{ir}" + ); + assert!( + typed_ir.contains("fcmp oge double") + && typed_ir.contains("fcmp ole double") + && typed_ir.contains("fcmp oeq double") + && !typed_ir.contains("@js_rel_"), + "typed predicate should contain only native f64 comparisons:\n{typed_ir}" + ); + assert!( + generic_ir.contains("call double @js_rel_ge(") + && generic_ir.contains("call double @js_rel_le("), + "generic fallback should preserve JS relational semantics:\n{generic_ir}" + ); + assert!( + wrapper_ir.contains(", 32761") + && wrapper_ir.contains(&format!("call i1 @{typed}(double ")) + && wrapper_ir.contains(&format!("call double @{generic_body}(")), + "public wrapper should guard once and retain the generic fallback:\n{wrapper_ir}" + ); + assert!( + caller_ir.contains("call double @js_string_code_point_at(") + && caller_ir.contains(&format!("call double @{public}(")) + && !caller_ir.contains(&format!("call i1 @{typed}(")), + "codePointAt's undefined case should use the guarded public entry:\n{caller_ir}" + ); +} + #[test] fn typed_i1_i32_predicate_function_uses_i32_params_and_public_wrapper() { let ir = From f01946abd549110dab4a6c00836140c18c979ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 22:59:47 +0200 Subject: [PATCH 2/5] chore(changelog): file the typed-predicate entry under its own PR number The entry was created under #9919, which is the GC pointer-validation PR. This change is PR #9921. --- ...typed-numeric-predicate.md => 9921-typed-numeric-predicate.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9919-typed-numeric-predicate.md => 9921-typed-numeric-predicate.md} (100%) diff --git a/changelog.d/9919-typed-numeric-predicate.md b/changelog.d/9921-typed-numeric-predicate.md similarity index 100% rename from changelog.d/9919-typed-numeric-predicate.md rename to changelog.d/9921-typed-numeric-predicate.md From ae8e15aa28491a63312a59050b14b70d4831a82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 07:14:18 +0200 Subject: [PATCH 3/5] Fix deep logical predicate return inference Infer long &&/|| chains with a bounded iterative walk so generated Boolean predicates retain their Boolean return type past the general recursion cap. Cover the exact YF6 source and its guarded codePointAt caller through HIR and LLVM lowering. --- crates/perry-codegen/Cargo.toml | 1 + .../perry-codegen/tests/fixtures/yf6_real.js | 1 + crates/perry-codegen/tests/yf6_admission.rs | 119 ++++++++++++++++++ crates/perry-hir/src/lower_types.rs | 54 ++++++-- 4 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 crates/perry-codegen/tests/fixtures/yf6_real.js create mode 100644 crates/perry-codegen/tests/yf6_admission.rs diff --git a/crates/perry-codegen/Cargo.toml b/crates/perry-codegen/Cargo.toml index 05faf97664..9b29a3d8db 100644 --- a/crates/perry-codegen/Cargo.toml +++ b/crates/perry-codegen/Cargo.toml @@ -76,3 +76,4 @@ llvm-sys = { version = "221", features = ["no-llvm-linking"], optional = true } # dist` still compile the library with `testing` off. [dev-dependencies] perry-codegen = { path = ".", features = ["testing"] } +perry-parser.workspace = true diff --git a/crates/perry-codegen/tests/fixtures/yf6_real.js b/crates/perry-codegen/tests/fixtures/yf6_real.js new file mode 100644 index 0000000000..604730a4de --- /dev/null +++ b/crates/perry-codegen/tests/fixtures/yf6_real.js @@ -0,0 +1 @@ +function YF6(q){return q>=4352&&q<=4447||q===8986||q===8987||q===9001||q===9002||q>=9193&&q<=9196||q===9200||q===9203||q===9725||q===9726||q===9748||q===9749||q>=9776&&q<=9783||q>=9800&&q<=9811||q===9855||q>=9866&&q<=9871||q===9875||q===9889||q===9898||q===9899||q===9917||q===9918||q===9924||q===9925||q===9934||q===9940||q===9962||q===9970||q===9971||q===9973||q===9978||q===9981||q===9989||q===9994||q===9995||q===10024||q===10060||q===10062||q>=10067&&q<=10069||q===10071||q>=10133&&q<=10135||q===10160||q===10175||q===11035||q===11036||q===11088||q===11093||q>=11904&&q<=11929||q>=11931&&q<=12019||q>=12032&&q<=12245||q>=12272&&q<=12287||q>=12289&&q<=12350||q>=12353&&q<=12438||q>=12441&&q<=12543||q>=12549&&q<=12591||q>=12593&&q<=12686||q>=12688&&q<=12773||q>=12783&&q<=12830||q>=12832&&q<=12871||q>=12880&&q<=42124||q>=42128&&q<=42182||q>=43360&&q<=43388||q>=44032&&q<=55203||q>=63744&&q<=64255||q>=65040&&q<=65049||q>=65072&&q<=65106||q>=65108&&q<=65126||q>=65128&&q<=65131||q>=94176&&q<=94180||q>=94192&&q<=94198||q>=94208&&q<=101589||q>=101631&&q<=101662||q>=101760&&q<=101874||q>=110576&&q<=110579||q>=110581&&q<=110587||q===110589||q===110590||q>=110592&&q<=110882||q===110898||q>=110928&&q<=110930||q===110933||q>=110948&&q<=110951||q>=110960&&q<=111355||q>=119552&&q<=119638||q>=119648&&q<=119670||q===126980||q===127183||q===127374||q>=127377&&q<=127386||q>=127488&&q<=127490||q>=127504&&q<=127547||q>=127552&&q<=127560||q===127568||q===127569||q>=127584&&q<=127589||q>=127744&&q<=127776||q>=127789&&q<=127797||q>=127799&&q<=127868||q>=127870&&q<=127891||q>=127904&&q<=127946||q>=127951&&q<=127955||q>=127968&&q<=127984||q===127988||q>=127992&&q<=128062||q===128064||q>=128066&&q<=128252||q>=128255&&q<=128317||q>=128331&&q<=128334||q>=128336&&q<=128359||q===128378||q===128405||q===128406||q===128420||q>=128507&&q<=128591||q>=128640&&q<=128709||q===128716||q>=128720&&q<=128722||q>=128725&&q<=128728||q>=128732&&q<=128735||q===128747||q===128748||q>=128756&&q<=128764||q>=128992&&q<=129003||q===129008||q>=129292&&q<=129338||q>=129340&&q<=129349||q>=129351&&q<=129535||q>=129648&&q<=129660||q>=129664&&q<=129674||q>=129678&&q<=129734||q===129736||q>=129741&&q<=129756||q>=129759&&q<=129770||q>=129775&&q<=129784||q>=131072&&q<=196605||q>=196608&&q<=262141} diff --git a/crates/perry-codegen/tests/yf6_admission.rs b/crates/perry-codegen/tests/yf6_admission.rs new file mode 100644 index 0000000000..428c0b2f82 --- /dev/null +++ b/crates/perry-codegen/tests/yf6_admission.rs @@ -0,0 +1,119 @@ +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; + +const YF6_REAL: &str = include_str!("fixtures/yf6_real.js"); + +fn defined_function_ir_section<'a>(ir: &'a str, symbol: &str) -> &'a str { + let needle = format!("@{symbol}("); + let mut search_start = 0; + let start = loop { + let relative = ir[search_start..] + .find(&needle) + .unwrap_or_else(|| panic!("function `{symbol}` definition not found in IR:\n{ir}")); + let symbol_start = search_start + relative; + let line_start = ir[..symbol_start] + .rfind('\n') + .map_or(0, |newline| newline + 1); + if ir[line_start..symbol_start] + .trim_start() + .starts_with("define ") + { + break line_start; + } + search_start = symbol_start + needle.len(); + }; + let rest = &ir[start..]; + let end = rest.find("\n}\n").map_or(rest.len(), |close| close + 3); + &rest[..end] +} + +#[test] +fn real_yf6_erased_predicate_gets_typed_i1_clone() { + // Keep YF6 byte-for-byte identical to the cc bundle. The typed caller is + // appended separately so codePointAt's possible `undefined` exercises the + // public guard instead of proving a raw-f64 direct call. + let source = + format!("{YF6_REAL}\nfunction caller(s: string){{return YF6(s.codePointAt(0))}}\n"); + + let ir = std::thread::Builder::new() + .name("real-yf6-codegen".to_string()) + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let ast = perry_parser::parse_typescript(&source, "yf6_admission.ts") + .expect("real YF6 should parse"); + let hir = perry_hir::lower_module(&ast, "yf6_admission.ts", "yf6_admission.ts") + .expect("real YF6 should lower"); + + let yf6 = hir + .functions + .iter() + .find(|function| function.name == "YF6") + .expect("YF6 should be a top-level HIR function"); + assert_eq!( + yf6.return_type, + Type::Boolean, + "the full logical chain must retain its inferred Boolean return type" + ); + + let options = CompileOptions { + emit_ir_only: true, + ..CompileOptions::default() + }; + String::from_utf8(compile_module(&hir, options).expect("real YF6 should codegen")) + .expect("LLVM IR should be UTF-8") + }) + .expect("spawn YF6 codegen thread") + .join() + .expect("YF6 codegen thread panicked"); + + let public = "perry_fn_yf6_admission_ts__YF6"; + let typed = "perry_fn_yf6_admission_ts__YF6$typed_i1"; + let generic = "perry_fn_yf6_admission_ts__YF6$generic"; + let caller = "perry_fn_yf6_admission_ts__caller"; + let typed_ir = defined_function_ir_section(&ir, typed); + let generic_ir = defined_function_ir_section(&ir, generic); + let wrapper_ir = defined_function_ir_section(&ir, public); + let caller_ir = defined_function_ir_section(&ir, caller); + + assert!( + typed_ir.starts_with(&format!("define internal i1 @{typed}(double ")), + "YF6 should have an f64-to-i1 typed clone:\n{typed_ir}" + ); + assert_eq!( + typed_ir.matches("fcmp oge double").count(), + 76, + "YF6 clone should lower every >= natively:\n{typed_ir}" + ); + assert_eq!( + typed_ir.matches("fcmp ole double").count(), + 76, + "YF6 clone should lower every <= natively:\n{typed_ir}" + ); + assert_eq!( + typed_ir.matches("fcmp oeq double").count(), + 60, + "YF6 clone should lower every === natively:\n{typed_ir}" + ); + assert!( + !typed_ir.contains("@js_rel_"), + "YF6 clone must not retain generic relational calls:\n{typed_ir}" + ); + + assert!( + generic_ir.contains("call double @js_rel_ge(") + && generic_ir.contains("call double @js_rel_le("), + "YF6's generic JSValue fallback must remain intact:\n{generic_ir}" + ); + assert!( + wrapper_ir.contains(", 32761") + && wrapper_ir.contains(&format!("call i1 @{typed}(double ")) + && wrapper_ir.contains(&format!("call double @{generic}(double ")), + "YF6's public wrapper must guard the value and retain both paths:\n{wrapper_ir}" + ); + assert!( + caller_ir.contains("call double @js_string_code_point_at(") + && caller_ir.contains(&format!("call double @{public}(double ")) + && !caller_ir.contains(&format!("call i1 @{typed}(double ")), + "codePointAt's undefined case must enter through YF6's guarded wrapper:\n{caller_ir}" + ); +} diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 0e44e0d3f0..cb44af140d 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -313,6 +313,12 @@ fn url_encoding_constructor_type(ctx: &LoweringContext, callee: &ast::Expr) -> O /// source never nests literals this deep, so the cap loses no practical /// precision while keeping pathological/minified inputs tractable. const INFER_TYPE_RECURSION_CAP: u32 = 48; +// Logical chains are common in generated/minified predicates and are normally +// left-deep. Walk them without spending one recursion level per `&&`/`||`, but +// retain a fixed work bound so repeated inference while lowering a pathological +// chain cannot restore #5258's unbounded O(n²) behavior. This admits up to 256 +// boolean-valued leaves, including cc's 212-comparison YF6 predicate. +const INFER_LOGICAL_CHAIN_NODE_CAP: usize = 512; const INFER_TYPE_STACK_RED_ZONE: usize = 256 * 1024; const INFER_TYPE_STACK_SEGMENT: usize = 2 * 1024 * 1024; @@ -343,6 +349,44 @@ pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> T }) } +/// Infer a tree made only from `&&` / `||` joins by flattening those joins onto +/// an explicit worklist. The ordinary logical inference rule is associative at +/// the type level: the result is a concrete type exactly when every leaf has +/// the same concrete type. Keeping non-logical leaves on the regular inference +/// path preserves all existing type rules. +fn infer_logical_chain_type(root: &ast::BinExpr, ctx: &LoweringContext) -> Type { + let mut pending = vec![root.right.as_ref(), root.left.as_ref()]; + let mut visited = 1usize; + let mut common: Option = None; + + while let Some(expr) = pending.pop() { + visited += 1; + if visited > INFER_LOGICAL_CHAIN_NODE_CAP { + return Type::Any; + } + + if let ast::Expr::Bin(bin) = expr { + if matches!(bin.op, ast::BinaryOp::LogicalAnd | ast::BinaryOp::LogicalOr) { + pending.push(bin.right.as_ref()); + pending.push(bin.left.as_ref()); + continue; + } + } + + let ty = infer_type_from_expr(expr, ctx); + if matches!(ty, Type::Any) { + return Type::Any; + } + match &common { + None => common = Some(ty), + Some(expected) if *expected == ty => {} + Some(_) => return Type::Any, + } + } + + common.unwrap_or(Type::Any) +} + fn infer_type_from_expr_inner(expr: &ast::Expr, ctx: &LoweringContext) -> Type { match expr { // Literals @@ -453,15 +497,7 @@ fn infer_type_from_expr_inner(expr: &ast::Expr, ctx: &LoweringContext) -> Type { // operand types match, else `Any` (dynamic truthiness/isArray). // Extends the #3527 fix (right = `Any` → `Any`) to the // mismatched-type case. - LogicalAnd | LogicalOr => { - let left = infer_type_from_expr(&bin.left, ctx); - let right = infer_type_from_expr(&bin.right, ctx); - if left == right && !matches!(right, Type::Any) { - right - } else { - Type::Any - } - } + LogicalAnd | LogicalOr => infer_logical_chain_type(bin, ctx), // One rule with the HIR-level `??` inference: an unknown left // stays unknown, only a nullish left takes the right type. // Pre-fix this arm answered the RIGHT type for an `Any` left, From de546e106bc4751b914ecad04fcbf5a1e58987ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 07:15:35 +0200 Subject: [PATCH 4/5] Document YF6 admission diagnosis Record the failing inference condition, the bounded iterative fix, validation status, and the requested perrymaster bundle and perf checks. --- .../codex/REPORT_yf6_admission.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 cc-perf-campaign/codex/REPORT_yf6_admission.md diff --git a/cc-perf-campaign/codex/REPORT_yf6_admission.md b/cc-perf-campaign/codex/REPORT_yf6_admission.md new file mode 100644 index 0000000000..df8bfb3d01 --- /dev/null +++ b/cc-perf-campaign/codex/REPORT_yf6_admission.md @@ -0,0 +1,104 @@ +# YF6 typed-i1 admission report + +## SHA + +Implementation commit: `ae8e15aa28491a63312a59050b14b70d4831a82c`. + +The implementation commit was pushed to +`fork/perf/typed-numeric-predicate`; `git ls-remote` resolved that branch to +the same SHA before this report-only child commit was created. + +## Failing condition + +The failure is return-type inference, not the erased-parameter proof, capture +analysis, typed-body safety walk, comparison floor, typed lowering, or a clone +instruction budget. + +`crates/perry-hir/src/lower_types.rs:315` caps recursive expression-type +inference at 48 levels, and `infer_type_from_expr` returns `Type::Any` at +`crates/perry-hir/src/lower_types.rs:343-345`. The old `LogicalAnd | +LogicalOr` rule recursively inferred both children. YF6's 135 left-associated +`||` joins exceed that depth, so an inner left subtree became `Any`; the +logical unification propagated `Any` to the root. `infer_body_return_type` +then rejected that `Any` at `crates/perry-hir/src/lower_types.rs:923-933`, and +`crates/perry-hir/src/lower_decl/fn_decl.rs:372-383` consequently left +`Function.return_type` at its initial `Any` fallback. The smaller probe's 22 +`||` joins remain below 48 and therefore infer `Boolean`. + +At codegen admission, that metadata produces +`TypedCloneRejectionReason::ReturnTypeNotI1` at +`crates/perry-codegen/src/codegen/typed_abi.rs:1277-1279`. +`typed_i1_function_rejection_reason` only tries erased-predicate admission when +the declared-path reason is `ParamNotI1` +(`crates/perry-codegen/src/codegen/typed_abi.rs:839-842`), so YF6 never reaches +the override. Calling `erased_numeric_predicate_param_reps` directly would +also fail its Boolean-return rule at +`crates/perry-codegen/src/codegen/typed_abi.rs:1210-1220`. + +All later rules pass once the return type is Boolean: + +- `typed_param_rep_for_type` deliberately does not type `Any` + (`crates/perry-codegen/src/codegen/typed_abi.rs:144-155`), after which the + erased rule sees referenced parameter `q` and assigns guarded `F64` + (`crates/perry-codegen/src/codegen/typed_abi.rs:1223-1239`). The real + top-level function has no captures. +- `typed_i1_body_rejection_reason` accepts the one-return straight-line body + (`crates/perry-codegen/src/codegen/typed_abi.rs:1705-1739`): every logical + node is `And`/`Or`, and every comparison has `q` plus an integer literal as + f64-safe operands. +- `numeric_comparisons_in_typed_i1_expr` + (`crates/perry-codegen/src/codegen/typed_abi.rs:1191-1207`) counts 212, which + clears `ERASED_NUMERIC_PREDICATE_MIN_COMPARISONS = 4` at line 1189. +- There is no per-function instruction budget in the typed top-level clone + selection or typed-i1 lowering path. + +## Change + +`LogicalAnd`/`LogicalOr` return inference now flattens logical joins onto an +explicit worklist (`crates/perry-hir/src/lower_types.rs:357-388`, selected at +line 500). It applies the same sound rule as before: every leaf must infer to +the same non-`Any` type. The general depth-48 guard remains intact. A separate +512-node work cap bounds repeated inference cost and prevents the unbounded +O(n²) behavior that the original guard protects against, while covering YF6's +423 logical/comparison AST nodes. + +`crates/perry-codegen/tests/yf6_admission.rs` parses and lowers the exact +2,274-byte YF6 fixture verbatim, appends a typed-string `codePointAt` caller, +and asserts: + +- the inferred YF6 return type is `Boolean`; +- `YF6$typed_i1` exists with 76 `fcmp oge`, 76 `fcmp ole`, and 60 `fcmp oeq`; +- the clone has no `@js_rel_` calls; +- the generic fallback remains present; +- the public wrapper guards and calls the clone while retaining the fallback; +- the possibly-`undefined` `codePointAt` result enters through that guarded + public wrapper. + +Restoring recursive logical inference makes the inferred-return assertion and +clone assertions fail. + +## Gates + +`df -g /` reported 0 GB available, below the binding 12 GB threshold. No cargo +invocation was made, so neither required cargo gate was run: + +- NOT RUN: `cargo test -p perry-codegen` +- NOT RUN: `cargo build --release -p perry` + +Non-cargo checks completed: `rustfmt --check`, `git diff --check`, exact fixture +`cmp`, source operator counts (76 `>=`, 76 `<=`, 60 `===`, 76 `&&`, 135 +`||`), and SHA-256 equality with the supplied source +(`ffa564fc9612a0a1011f60d343d43777d082437547af2679ecb1568f478d1051`). + +## Perrymaster request + +Rebuild the compiler from implementation SHA +`ae8e15aa28491a63312a59050b14b70d4831a82c` on the I7-view tree, compile the +cc bundle with I7-view's configuration, and verify: + +```sh +nm | grep -c 'perry_fn_cli_2_1_112_js__YF6\$typed_i1' +``` + +The identity must be `1`. Then collect the rows versus I7-view and the perf +draw, using marker `YF6` self from the 9.46% baseline. From 4d95f54231039757437598c64b1b1b9fe0070334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 10:09:19 +0200 Subject: [PATCH 5/5] test(codegen): the YF6 regression reads the caller and its specialisation clones The fixture caller takes a `string`, so the lowering splits it into `caller$spec_b` / `caller$generic` behind `js_typed_string_arg_guard`; the guarded YF6 wrapper call lives in those clones, not in the public entry the assertion read. Assert over the union of the caller and its clones. --- crates/perry-codegen/tests/yf6_admission.rs | 44 ++++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/tests/yf6_admission.rs b/crates/perry-codegen/tests/yf6_admission.rs index 428c0b2f82..95460de36b 100644 --- a/crates/perry-codegen/tests/yf6_admission.rs +++ b/crates/perry-codegen/tests/yf6_admission.rs @@ -27,6 +27,44 @@ fn defined_function_ir_section<'a>(ir: &'a str, symbol: &str) -> &'a str { &rest[..end] } +/// The IR of `symbol` together with every specialisation clone the lowering +/// may have split it into (`symbol$spec_*`, `symbol$generic`, ...). A caller +/// whose parameter is typed `string` is lowered as a guarded pair of clones +/// behind `js_typed_string_arg_guard`, so the call it makes lives in the +/// clones, not in the public entry. +fn defined_function_ir_sections_with_clones(ir: &str, symbol: &str) -> String { + let mut out = String::new(); + let mut search_start = 0; + while let Some(relative) = ir[search_start..].find("define ") { + let line_start = search_start + relative; + let line_end = ir[line_start..] + .find('\n') + .map_or(ir.len(), |newline| line_start + newline); + let line = &ir[line_start..line_end]; + let defines_symbol_or_clone = line + .find(&format!("@{symbol}")) + .map(|at| { + let after = &line[at + symbol.len() + 1..]; + after.starts_with('(') || after.starts_with('$') + }) + .unwrap_or(false); + if defines_symbol_or_clone { + let rest = &ir[line_start..]; + let end = rest.find("\n}\n").map_or(rest.len(), |close| close + 3); + out.push_str(&rest[..end]); + out.push('\n'); + search_start = line_start + end; + } else { + search_start = line_end.max(line_start + 1); + } + } + assert!( + !out.is_empty(), + "function `{symbol}` (or a clone of it) not found in IR:\n{ir}" + ); + out +} + #[test] fn real_yf6_erased_predicate_gets_typed_i1_clone() { // Keep YF6 byte-for-byte identical to the cc bundle. The typed caller is @@ -73,7 +111,9 @@ fn real_yf6_erased_predicate_gets_typed_i1_clone() { let typed_ir = defined_function_ir_section(&ir, typed); let generic_ir = defined_function_ir_section(&ir, generic); let wrapper_ir = defined_function_ir_section(&ir, public); - let caller_ir = defined_function_ir_section(&ir, caller); + // The caller takes a `string`, so the lowering may split it into guarded + // specialisation clones; the YF6 call is in whichever clone carries the body. + let caller_ir = defined_function_ir_sections_with_clones(&ir, caller); assert!( typed_ir.starts_with(&format!("define internal i1 @{typed}(double ")), @@ -114,6 +154,6 @@ fn real_yf6_erased_predicate_gets_typed_i1_clone() { caller_ir.contains("call double @js_string_code_point_at(") && caller_ir.contains(&format!("call double @{public}(double ")) && !caller_ir.contains(&format!("call i1 @{typed}(double ")), - "codePointAt's undefined case must enter through YF6's guarded wrapper:\n{caller_ir}" + "codePointAt's undefined case must enter through YF6's guarded wrapper (in the caller or any of its specialisation clones):\n{caller_ir}" ); }