From 2052a5274c09227ea4c97b534df0335ce6ab116e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 16:31:10 +0200 Subject: [PATCH] perf(codegen): specialize undefined loop filters Lands #8740. Versions eligible instance methods with one private exact-`undefined` body when an immutable optional parameter guards work inside a loop, retaining the public boxed ABI and branching on the live argument's exact TAG_UNDEFINED bits. All other values run the unchanged generic body. The TypeScript optional annotation only nominates a candidate and is never consumed as a runtime proof: the private clone receives `Type::Void` only behind the public wrapper's live bit-compare, which is emitted as `icmp_eq(I64, &arg_bits, TAG_UNDEFINED_I64)` and pinned by a test. Candidate discovery rejects async/generator methods, rest and `arguments` parameters, every user-authored parameter write, and closure capture. The linkage interaction with #8731 is resolved. #8731 narrowed the module-local condition from `is_pshape_clone` to `ptr_array_cache_clone` because plain `$pshape` clones became producer-published capabilities needing external linkage. Undefined-filter candidacy now excludes index and array-cache clones rather than forcing itself module-local, three `debug_assert!` invariants pin that a guarded-undefined clone is never also one of those, and a new test asserts the pshape family's guard wrapper stays a published capability carrying both `$undef0` and `$generic`. One fix on top: `pshape_symbol_reachability` scans the source tree for `$pshape` fragments outside a 7-entry allowlist, so that the clone symbol can never reach a runtime vtable. The PR's new `guarded_undefined_method_tests.rs` names the fragment in its wrapper assertions, exactly as the two test files already on that list do. It is allowlisted with a rationale; the gate's emission-site coverage is unchanged. No version bump. --- .../8740-guarded-undefined-method-param.md | 11 + .../src/codegen/artifact_context.rs | 64 +++++ crates/perry-codegen/src/codegen/artifacts.rs | 157 ++++++----- .../src/codegen/closure_collect.rs | 6 +- .../codegen/guarded_undefined_method_tests.rs | 218 +++++++++++++++ crates/perry-codegen/src/codegen/method.rs | 263 ++++-------------- .../src/codegen/method_trampolines.rs | 257 +++++++++++++++++ crates/perry-codegen/src/codegen/mod.rs | 46 ++- crates/perry-codegen/src/codegen/opts.rs | 5 + .../perry-codegen/src/codegen/param_guard.rs | 175 ++++++++++++ .../src/collectors/proven_this.rs | 3 +- crates/perry-codegen/src/stmt/if_stmt.rs | 8 + .../src/async_to_generator_tests.rs | 5 +- crates/perry-transform/src/generator/lower.rs | 3 +- scripts/local_binding_type_allowlist.json | 8 + .../test_guarded_undefined_method_param.ts | 84 ++++++ 16 files changed, 1032 insertions(+), 281 deletions(-) create mode 100644 changelog.d/8740-guarded-undefined-method-param.md create mode 100644 crates/perry-codegen/src/codegen/artifact_context.rs create mode 100644 crates/perry-codegen/src/codegen/guarded_undefined_method_tests.rs create mode 100644 crates/perry-codegen/src/codegen/method_trampolines.rs create mode 100644 test-files/test_guarded_undefined_method_param.ts diff --git a/changelog.d/8740-guarded-undefined-method-param.md b/changelog.d/8740-guarded-undefined-method-param.md new file mode 100644 index 0000000000..cdddb28913 --- /dev/null +++ b/changelog.d/8740-guarded-undefined-method-param.md @@ -0,0 +1,11 @@ +Versioned eligible instance methods for an exact `undefined` optional argument. +The public boxed-ABI wrapper validates the live argument bits once and sends only +that value to a private specialized body; functions and every other falsey or +non-callable value retain the ordinary JavaScript path. Mutation, closure capture, +async/generator bodies, and oversized methods are conservatively excluded. + +This removes the per-entity optional-filter truthiness and callback arm from +codehz/ecs's 10k-entity accumulation loop. On an Apple M1 Mac mini, 11 +alternating process pairs measured 0.179437 ms versus 0.195817 ms on the exact +parent, an 8.376% median paired improvement with 11/11 wins and all output +oracles passing. diff --git a/crates/perry-codegen/src/codegen/artifact_context.rs b/crates/perry-codegen/src/codegen/artifact_context.rs new file mode 100644 index 0000000000..cf65023593 --- /dev/null +++ b/crates/perry-codegen/src/codegen/artifact_context.rs @@ -0,0 +1,64 @@ +//! Borrowed inputs for the module artifact-emission phase. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::Module as HirModule; + +use crate::module::LlModule; +use crate::strings::StringPool; + +use super::opts::CrossModuleCtx; + +/// Read-only view of the `CompileOptions` fields that artifact emission still +/// references after the pipeline has moved other fields into `CrossModuleCtx`. +pub(super) struct OptsView<'a> { + pub(super) import_function_prefixes: &'a HashMap, + pub(super) imported_classes: &'a [super::opts::ImportedClass], + pub(super) is_entry_module: bool, + pub(super) non_entry_module_prefixes: &'a [String], + pub(super) output_type: &'a str, +} + +/// Data computed by the `compile_module` prelude and borrowed by the artifact +/// tail. Keeping it together avoids a second oversized compiler entry module. +pub(super) struct ModuleArtifactsCtx<'a> { + pub progress: &'a super::CompileProgress, + pub llmod: &'a mut LlModule, + pub target_triple: &'a str, + pub strings: &'a mut StringPool, + pub hir: &'a HirModule, + pub import_function_prefixes: &'a HashMap, + pub imported_classes: &'a [super::opts::ImportedClass], + pub is_entry_module: bool, + pub non_entry_module_prefixes: &'a [String], + pub output_type: &'a str, + pub module_prefix: &'a String, + pub class_table: &'a HashMap, + pub class_ids: &'a HashMap, + pub enum_table: &'a HashMap<(String, String), perry_hir::EnumValue>, + pub module_globals: &'a HashMap, + pub module_global_types: &'a HashMap, + pub static_field_globals: &'a HashMap<(String, String), String>, + pub method_names: &'a HashMap<(String, String), String>, + pub func_names: &'a HashMap, + pub func_signatures: &'a HashMap, + pub func_synthetic_arguments: &'a HashSet, + pub module_boxed_vars: &'a HashSet, + /// Typed-ABI capture oracle: module-wide local types minus boxed ids. + pub module_local_types: &'a HashMap, + /// Source-type metadata for closure receivers; not a representation proof. + pub module_receiver_types: &'a HashMap, + pub closure_rest_params: &'a HashMap, + pub closure_synthetic_arguments: &'a HashSet, + pub closure_rest_and_arguments: &'a HashSet, + pub closure_arities: &'a HashMap, + pub closure_lengths: &'a HashMap, + pub closure_arrow_functions: &'a HashSet, + pub trusted_box_closures: &'a HashMap, + pub closures: &'a [(perry_hir::types::FuncId, perry_hir::Expr)], + pub class_keys_init_data: &'a [(String, String, u32, Vec, Vec)], + /// Keys global to `(class id, packed GcHeader word)` for inline `new`. + pub class_header_image_inits: &'a HashMap, + pub imported_class_stubs: &'a [perry_hir::Class], + pub cross_module: &'a CrossModuleCtx, +} diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 550b0bad71..8a15a2b568 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -6,12 +6,10 @@ use std::collections::{HashMap, HashSet}; use std::time::Instant; use anyhow::{Context, Result}; -use perry_hir::Module as HirModule; -use crate::module::LlModule; -use crate::strings::StringPool; use crate::types::{LlvmType, DOUBLE, I64, VOID}; +use super::artifact_context::{ModuleArtifactsCtx, OptsView}; use super::closure::{ compile_closure, compile_typed_f64_closure, compile_typed_i1_closure, compile_typed_i32_closure, compile_typed_string_closure, @@ -27,78 +25,9 @@ use super::method::{ compile_typed_string_method, }; use super::native_namespace_exports::emit_native_namespace_reexport_getters; -use super::opts::CrossModuleCtx; use super::spec_function_length; -use super::typed_abi::TypedFunctionTrampolineKind; - -/// Read-only view of the `CompileOptions` fields that the artifact -/// emission step references via `opts.X`. Bundled into a struct so the -/// moved block (originally written against `let opts = …;` of type -/// `CompileOptions`) can keep its `opts.X` syntax without holding a -/// `&CompileOptions` borrow — that borrow is unavailable at the call -/// site, because `compile_module`'s prelude moves several `opts` -/// fields into `CrossModuleCtx` before invoking this function. -struct OptsView<'a> { - import_function_prefixes: &'a std::collections::HashMap, - imported_classes: &'a [super::opts::ImportedClass], - is_entry_module: bool, - non_entry_module_prefixes: &'a [String], - output_type: &'a str, -} use super::string_pool::emit_string_pool; - -/// All the data computed by the prelude of `compile_module` that the -/// tail half (this file) needs. Bundled so the call from -/// `compile_module` stays a single line; field names mirror the -/// in-prelude local names so the moved block reads unchanged once -/// destructured. -pub(super) struct ModuleArtifactsCtx<'a> { - pub progress: &'a super::CompileProgress, - pub llmod: &'a mut LlModule, - pub target_triple: &'a str, - pub strings: &'a mut StringPool, - pub hir: &'a HirModule, - pub import_function_prefixes: &'a std::collections::HashMap, - pub imported_classes: &'a [super::opts::ImportedClass], - pub is_entry_module: bool, - pub non_entry_module_prefixes: &'a [String], - pub output_type: &'a str, - pub module_prefix: &'a String, - pub class_table: &'a HashMap, - pub class_ids: &'a HashMap, - pub enum_table: &'a HashMap<(String, String), perry_hir::EnumValue>, - pub module_globals: &'a HashMap, - pub module_global_types: &'a HashMap, - pub static_field_globals: &'a HashMap<(String, String), String>, - pub method_names: &'a HashMap<(String, String), String>, - pub func_names: &'a HashMap, - pub func_signatures: &'a HashMap, - pub func_synthetic_arguments: &'a std::collections::HashSet, - pub module_boxed_vars: &'a std::collections::HashSet, - /// Typed-ABI capture-representation oracle: module-wide `Stmt::Let` types - /// MINUS boxed ids (#5869). Only the typed closure clones read this. - pub module_local_types: &'a HashMap, - /// #6369: receiver-type oracle for closure bodies — the same module-wide - /// `Stmt::Let` types with no representation filtering, mirroring the - /// `module_global_types` seed that `compile_function` / `compile_method` - /// already use. Feeds `FnCtx.local_types` only. - pub module_receiver_types: &'a HashMap, - pub closure_rest_params: &'a HashMap, - pub closure_synthetic_arguments: &'a std::collections::HashSet, - pub closure_rest_and_arguments: &'a std::collections::HashSet, - pub closure_arities: &'a HashMap, - pub closure_lengths: &'a HashMap, - pub closure_arrow_functions: &'a std::collections::HashSet, - pub trusted_box_closures: - &'a std::collections::HashMap, - pub closures: &'a [(perry_hir::types::FuncId, perry_hir::Expr)], - pub class_keys_init_data: &'a [(String, String, u32, Vec, Vec)], - /// #8122: keys global → (class id, packed GcHeader word) for the classes - /// whose inline-`new` header image module init must compose. - pub class_header_image_inits: &'a std::collections::HashMap, - pub imported_class_stubs: &'a [perry_hir::Class], - pub cross_module: &'a CrossModuleCtx, -} +use super::typed_abi::TypedFunctionTrampolineKind; /// Emit the artifact tail: bodies, wrappers, namespace globals, entry /// function, string pool. Mirrors the in-prelude execution order of @@ -393,6 +322,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, Some(nonnegative_index_params), false, + false, ) .with_context(|| { format!( @@ -427,8 +357,46 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?; + if cross_module + .guarded_undefined_method_params + .contains_key(&(class.name.clone(), method.name.clone())) + { + compile_method( + llmod, + class, + method, + func_names, + strings, + class_table, + method_names, + module_globals, + module_global_types, + opts.import_function_prefixes, + enum_table, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + None, + None, + false, + true, + ) + .with_context(|| { + format!( + "lowering exact-undefined clone of method '{}::{}'", + class.name, method.name + ) + })?; + } // Representation-selection Phase 5a: the additive `internal` // proven-`this` clone. Same HIR, same ABI, same shadow-bound // tagged-at-rest receiver slot — only `this.field` lowering @@ -464,6 +432,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { Some(fact.clone()), None, false, + false, ) .with_context(|| { format!( @@ -471,6 +440,43 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { class.name, method.name ) })?; + if cross_module + .guarded_undefined_method_params + .contains_key(&(class.name.clone(), method.name.clone())) + { + compile_method( + llmod, + class, + method, + func_names, + strings, + class_table, + method_names, + module_globals, + module_global_types, + opts.import_function_prefixes, + enum_table, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + Some(fact.clone()), + None, + false, + true, + ) + .with_context(|| { + format!( + "lowering proven-`this` exact-undefined clone of method '{}::{}'", + class.name, method.name + ) + })?; + } // #8607: a second, stricter clone for the Phase 3b // provenance+containment route. Its synthetic immutable @@ -505,6 +511,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { Some(fact.clone()), None, true, + false, ) .with_context(|| { format!( @@ -544,6 +551,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| { format!( @@ -611,6 +619,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering getter '{}::{}'", class.name, prop))?; } @@ -666,6 +675,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering setter '{}::{}'", class.name, prop))?; } @@ -763,6 +773,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering constructor for '{}'", class.name))?; } diff --git a/crates/perry-codegen/src/codegen/closure_collect.rs b/crates/perry-codegen/src/codegen/closure_collect.rs index d14bb1f31c..ab56a53de1 100644 --- a/crates/perry-codegen/src/codegen/closure_collect.rs +++ b/crates/perry-codegen/src/codegen/closure_collect.rs @@ -241,6 +241,10 @@ fn count_stmt_nodes(stmt: &perry_hir::Stmt) -> usize { count } +pub(super) fn count_body_nodes(body: &[perry_hir::Stmt]) -> usize { + body.iter().map(count_stmt_nodes).sum() +} + pub(crate) fn select_trusted_box_closures( closures: &[(perry_hir::types::FuncId, perry_hir::Expr)], direct_call_closures: &std::collections::HashSet, @@ -298,7 +302,7 @@ pub(crate) fn select_trusted_box_closures( if boxed_capture_mask == 0 { return None; } - let cost = body.iter().map(count_stmt_nodes).sum::(); + let cost = count_body_nodes(body); (cost <= MAX_TRUSTED_BOX_CLONE_NODES).then_some(( cost, *func_id, diff --git a/crates/perry-codegen/src/codegen/guarded_undefined_method_tests.rs b/crates/perry-codegen/src/codegen/guarded_undefined_method_tests.rs new file mode 100644 index 0000000000..07be0f146c --- /dev/null +++ b/crates/perry-codegen/src/codegen/guarded_undefined_method_tests.rs @@ -0,0 +1,218 @@ +//! Exact-`undefined` optional method versioning. + +use crate::{compile_module, CompileOptions}; +use perry_hir::types::{FunctionType, Type}; +use perry_hir::{Class, CompareOp, Expr, Function, LogicalOp, Module, Param, Stmt}; + +const FILTER: u32 = 20; + +fn callback_type() -> Type { + Type::Function(FunctionType { + params: vec![("value".to_string(), Type::Any, false)], + return_type: Box::new(Type::Boolean), + is_async: false, + is_generator: false, + }) +} + +fn optional_filter() -> Param { + Param { + id: FILTER, + name: "filter".to_string(), + ty: callback_type(), + default: Some(Expr::Undefined), + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn synthetic_optional_prologue() -> Stmt { + Stmt::If { + condition: Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(FILTER)), + right: Box::new(Expr::Undefined), + }, + then_branch: vec![Stmt::Expr(Expr::LocalSet( + FILTER, + Box::new(Expr::Undefined), + ))], + else_branch: None, + } +} + +fn loop_filter_guard() -> Stmt { + Stmt::For { + init: None, + condition: Some(Expr::Bool(false)), + update: None, + body: vec![Stmt::If { + condition: Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::LocalGet(FILTER)), + right: Box::new(Expr::Call { + callee: Box::new(Expr::LocalGet(FILTER)), + args: vec![Expr::Integer(1)], + type_args: Vec::new(), + byte_offset: 0, + }), + }, + then_branch: vec![Stmt::Expr(Expr::Integer(1))], + else_branch: None, + }], + } +} + +fn method(extra: Vec) -> Function { + let mut body = vec![synthetic_optional_prologue(), loop_filter_guard()]; + body.extend(extra); + Function { + id: 90, + name: "scan".to_string(), + type_params: Vec::new(), + params: vec![optional_filter()], + return_type: Type::Void, + body, + 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, + } +} + +fn class(method: Function) -> Class { + Class { + id: 100, + name: "Scanner".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: vec![method], + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn emit(method: Function) -> String { + let mut module = Module::new("guarded_undefined_method.ts"); + module.classes = vec![class(method)]; + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + String::from_utf8(compile_module(&module, opts).expect("fixture compiles")) + .expect("LLVM IR is UTF-8") +} + +fn function_body<'a>(ir: &'a str, marker: &str) -> &'a str { + let start = ir + .match_indices("define ") + .find(|(index, _)| { + let end = ir[*index..] + .find('\n') + .map(|offset| index + offset) + .unwrap_or(ir.len()); + ir[*index..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] +} + +#[test] +fn wrapper_guards_actual_bits_and_clone_erases_the_loop_filter_arm() { + let ir = emit(method(Vec::new())); + let base = "perry_method_guarded_undefined_method_ts__Scanner__scan"; + let wrapper = function_body(&ir, &format!("@{base}(")); + let generic = function_body(&ir, &format!("@{base}$generic(")); + let undefined = function_body(&ir, &format!("@{base}$undef0(")); + + assert!( + wrapper.starts_with("define double "), + "the guarded wrapper is a published capability; only its bodies are private:\n{wrapper}" + ); + assert!(wrapper.contains(&crate::nanbox::TAG_UNDEFINED_I64.to_string())); + assert!(wrapper.contains(&format!("@{base}$undef0("))); + assert!(wrapper.contains(&format!("@{base}$generic("))); + assert!(generic.contains("@js_is_truthy("), "{generic}"); + assert!(generic.contains("@js_closure_call1("), "{generic}"); + assert!( + !undefined.contains("@js_is_truthy("), + "the guarded clone must not test the known-falsy filter in its loop:\n{undefined}" + ); + assert!( + !undefined.contains("@js_closure_call1("), + "the conditional filter call must be unreachable in the clone:\n{undefined}" + ); +} + +#[test] +fn a_pshape_family_guard_wrapper_is_a_published_capability() { + let candidate = method(Vec::new()); + let base = "perry_method_guarded_undefined_method_ts__Scanner__scan$pshape"; + let mut llmod = crate::module::LlModule::new(super::default_target_triple()); + super::method_trampolines::emit_guarded_undefined( + &mut llmod, + &candidate, + base, + &format!("{base}$generic"), + 0, + ); + let ir = llmod.to_ir(); + let wrapper = function_body(&ir, &format!("@{base}(")); + assert!( + wrapper.starts_with("define double "), + "the producer-published `$pshape` wrapper must retain external linkage:\n{wrapper}" + ); + assert!(wrapper.contains(&format!("@{base}$undef0("))); + assert!(wrapper.contains(&format!("@{base}$generic("))); +} + +#[test] +fn a_real_parameter_write_keeps_one_unspecialized_public_body() { + let mut candidate = method(Vec::new()); + candidate.body.push(Stmt::Expr(Expr::LocalSet( + FILTER, + Box::new(Expr::Bool(true)), + ))); + let ir = emit(candidate); + let base = "perry_method_guarded_undefined_method_ts__Scanner__scan"; + let public = function_body(&ir, &format!("@{base}(")); + assert!(public.contains("@js_is_truthy(")); + assert!(!ir.contains(&format!("@{base}$undef0("))); + assert!(!ir.contains(&format!("@{base}$generic("))); +} + +#[test] +fn an_oversized_method_does_not_consume_the_full_body_clone_budget() { + let padding = (0..1_025) + .map(|value| Stmt::Expr(Expr::Integer(value))) + .collect(); + assert!(super::param_guard::guarded_undefined_method_candidate(&method(padding)).is_none()); +} diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index b4779ce74a..18bd01d7da 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -13,203 +13,18 @@ use crate::strings::StringPool; use crate::types::{LlvmType, DOUBLE, I1, I32, I64, PTR}; use super::helpers::scoped_static_method_name; +use super::method_trampolines::{ + emit_guarded_undefined, emit_public_generic, emit_public_typed, guarded_undefined_name, +}; use super::opts::CrossModuleCtx; use super::typed_abi::{ - emit_typed_arg_guard, emit_typed_arg_to_raw, generic_method_body_name, lower_typed_f64_body, - lower_typed_f64_receiver_body, lower_typed_i1_body, lower_typed_i32_body, - lower_typed_string_body, typed_f64_method_name, typed_f64_receiver_method_name, - typed_i1_method_name, typed_i32_method_name, typed_param_reps_for_params, - typed_string_method_name, TypedFunctionTrampolineKind, TypedParamRep, TypedReceiverMethodInfo, + generic_method_body_name, lower_typed_f64_body, lower_typed_f64_receiver_body, + lower_typed_i1_body, lower_typed_i32_body, lower_typed_string_body, typed_f64_method_name, + typed_f64_receiver_method_name, typed_i1_method_name, typed_i32_method_name, + typed_param_reps_for_params, typed_string_method_name, TypedFunctionTrampolineKind, + TypedReceiverMethodInfo, }; -fn emit_typed_method_trampoline_fast_value( - blk: &mut crate::block::LlBlock, - kind: TypedFunctionTrampolineKind, - typed_name: &str, - arg_names: &[String], - arg_reps: &[TypedParamRep], -) -> String { - match kind { - TypedFunctionTrampolineKind::F64 => { - let raw_args: Vec = arg_names - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) - .collect(); - let typed_args: Vec<(LlvmType, &str)> = raw_args - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) - .collect(); - blk.call(DOUBLE, typed_name, &typed_args) - } - TypedFunctionTrampolineKind::I32 => { - let raw_args: Vec = arg_names - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) - .collect(); - let typed_args: Vec<(LlvmType, &str)> = raw_args - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) - .collect(); - let raw_i32 = blk.call(I32, typed_name, &typed_args); - crate::expr::i32_to_nanbox(blk, &raw_i32) - } - TypedFunctionTrampolineKind::I1 => { - let raw_args: Vec = arg_names - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) - .collect(); - let typed_args: Vec<(LlvmType, &str)> = raw_args - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) - .collect(); - let typed_i1 = blk.call(I1, typed_name, &typed_args); - let typed_i32 = blk.zext(I1, &typed_i1, I32); - crate::expr::i32_bool_to_nanbox(blk, &typed_i32) - } - TypedFunctionTrampolineKind::StringRef => { - let raw_args: Vec = arg_names - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) - .collect(); - let typed_args: Vec<(LlvmType, &str)> = raw_args - .iter() - .zip(arg_reps.iter()) - .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) - .collect(); - let raw_string = blk.call(I64, typed_name, &typed_args); - blk.call(DOUBLE, "js_nanbox_string", &[(I64, &raw_string)]) - } - } -} - -fn emit_public_typed_method_trampoline( - llmod: &mut LlModule, - method: &Function, - public_name: &str, - generic_body_name: &str, - kind: TypedFunctionTrampolineKind, -) { - let typed_name = match kind { - TypedFunctionTrampolineKind::F64 => typed_f64_method_name(public_name), - TypedFunctionTrampolineKind::I32 => typed_i32_method_name(public_name), - TypedFunctionTrampolineKind::I1 => typed_i1_method_name(public_name), - TypedFunctionTrampolineKind::StringRef => typed_string_method_name(public_name), - }; - let arg_reps = match kind { - TypedFunctionTrampolineKind::F64 => typed_param_reps_for_params(&method.params) - .unwrap_or_else(|| vec![TypedParamRep::F64; method.params.len()]), - TypedFunctionTrampolineKind::I32 => typed_param_reps_for_params(&method.params) - .unwrap_or_else(|| vec![TypedParamRep::I32; method.params.len()]), - TypedFunctionTrampolineKind::I1 => typed_param_reps_for_params(&method.params) - .unwrap_or_else(|| vec![TypedParamRep::I1; method.params.len()]), - TypedFunctionTrampolineKind::StringRef => typed_param_reps_for_params(&method.params) - .unwrap_or_else(|| vec![TypedParamRep::StringRef; method.params.len()]), - }; - let mut params: Vec<(LlvmType, String)> = Vec::with_capacity(method.params.len() + 1); - params.push((DOUBLE, "%this_arg".to_string())); - for p in &method.params { - params.push((DOUBLE, format!("%arg{}", p.id))); - } - let arg_names: Vec = method - .params - .iter() - .map(|p| format!("%arg{}", p.id)) - .collect(); - let wf = llmod.define_function(public_name, DOUBLE, params); - let _ = wf.create_block("entry"); - - let mut guard: Option = None; - { - let blk = wf.block_mut(0).unwrap(); - for (arg, rep) in arg_names.iter().zip(arg_reps.iter()) { - let ok = emit_typed_arg_guard(blk, *rep, arg); - guard = Some(match guard { - Some(prev) => blk.and(I1, &prev, &ok), - None => ok, - }); - } - } - - let Some(guard) = guard else { - let value = emit_typed_method_trampoline_fast_value( - wf.block_mut(0).unwrap(), - kind, - &typed_name, - &arg_names, - &arg_reps, - ); - wf.block_mut(0).unwrap().ret(DOUBLE, &value); - return; - }; - - let fast_idx = wf.num_blocks(); - let fast_label = wf.create_block("typed_method_public.fast").label.clone(); - let fallback_idx = wf.num_blocks(); - let fallback_label = wf - .create_block("typed_method_public.fallback") - .label - .clone(); - wf.block_mut(0) - .unwrap() - .cond_br(&guard, &fast_label, &fallback_label); - - let fast_value = emit_typed_method_trampoline_fast_value( - wf.block_mut(fast_idx).unwrap(), - kind, - &typed_name, - &arg_names, - &arg_reps, - ); - wf.block_mut(fast_idx).unwrap().ret(DOUBLE, &fast_value); - - let mut call_args: Vec<(LlvmType, &str)> = Vec::with_capacity(arg_names.len() + 1); - call_args.push((DOUBLE, "%this_arg")); - for arg in &arg_names { - call_args.push((DOUBLE, arg.as_str())); - } - let fallback_value = - wf.block_mut(fallback_idx) - .unwrap() - .call(DOUBLE, generic_body_name, &call_args); - wf.block_mut(fallback_idx) - .unwrap() - .ret(DOUBLE, &fallback_value); -} - -fn emit_public_generic_method_forwarder( - llmod: &mut LlModule, - method: &Function, - public_name: &str, - generic_body_name: &str, -) { - let mut params: Vec<(LlvmType, String)> = Vec::with_capacity(method.params.len() + 1); - params.push((DOUBLE, "%this_arg".to_string())); - for p in &method.params { - params.push((DOUBLE, format!("%arg{}", p.id))); - } - let wf = llmod.define_function(public_name, DOUBLE, params); - let _ = wf.create_block("entry"); - let mut arg_names: Vec = Vec::with_capacity(method.params.len() + 1); - arg_names.push("%this_arg".to_string()); - for p in &method.params { - arg_names.push(format!("%arg{}", p.id)); - } - let call_args: Vec<(LlvmType, &str)> = - arg_names.iter().map(|arg| (DOUBLE, arg.as_str())).collect(); - let value = wf - .block_mut(0) - .unwrap() - .call(DOUBLE, generic_body_name, &call_args); - wf.block_mut(0).unwrap().ret(DOUBLE, &value); -} - fn node_stream_parent_kind( classes: &HashMap, class: &perry_hir::Class, @@ -264,6 +79,7 @@ pub(super) fn compile_method( proven_this: Option, nonnegative_index_params: Option<&[u32]>, ptr_array_cache_clone: bool, + guarded_undefined_clone: bool, ) -> Result<()> { let public_llvm_name = methods .get(&(class.name.clone(), method.name.clone())) @@ -282,14 +98,37 @@ pub(super) fn compile_method( // primary (`proven_this: None`) invocation for this same method. let is_pshape_clone = proven_this.is_some(); let is_index_clone = nonnegative_index_params.is_some(); + let guarded_undefined_param = (!is_index_clone && !ptr_array_cache_clone) + .then(|| { + cross_module + .guarded_undefined_method_params + .get(&(class.name.clone(), method.name.clone())) + .copied() + }) + .flatten(); debug_assert!(!(is_pshape_clone && is_index_clone)); debug_assert!(!ptr_array_cache_clone || is_pshape_clone); - let llvm_name = if let Some(params) = nonnegative_index_params { - crate::codegen::nonnegative_index_method_name(&public_llvm_name, params) - } else if ptr_array_cache_clone { + debug_assert!(!guarded_undefined_clone || guarded_undefined_param.is_some()); + debug_assert!(!guarded_undefined_clone || !is_index_clone); + debug_assert!(!guarded_undefined_clone || !ptr_array_cache_clone); + let family_name = if ptr_array_cache_clone { crate::collectors::ptr_array_cache_method_name(&public_llvm_name) } else if is_pshape_clone { crate::collectors::pshape_method_name(&public_llvm_name) + } else { + public_llvm_name.clone() + }; + let llvm_name = if let Some(params) = nonnegative_index_params { + crate::codegen::nonnegative_index_method_name(&public_llvm_name, params) + } else if guarded_undefined_clone { + guarded_undefined_name( + &family_name, + guarded_undefined_param.expect("undefined clone parameter"), + ) + } else if guarded_undefined_param.is_some() { + generic_method_body_name(&family_name) + } else if ptr_array_cache_clone || is_pshape_clone { + family_name.clone() } else if typed_public_trampoline.is_some() || force_generic_body { generic_method_body_name(&public_llvm_name) } else { @@ -309,11 +148,14 @@ pub(super) fn compile_method( // Plain `$pshape` clones are producer-published capabilities and need // external linkage for guarded calls from importing modules. The stricter // array-cache clone remains module-local: only containment-proven locals - // in this module may select it. + // in this module may select it. An exact-undefined candidate names this + // body `$pshape$generic` (or `$undefN`) and publishes a separate guarded + // `$pshape` wrapper, so its implementation bodies also remain private. if ptr_array_cache_clone || is_index_clone || typed_public_trampoline.is_some() || force_generic_body + || guarded_undefined_param.is_some() { lf.linkage = "internal".to_string(); } @@ -404,6 +246,9 @@ pub(super) fn compile_method( for p in &method.params { local_types.insert(p.id, p.ty.clone()); } + if let Some(index) = guarded_undefined_param.filter(|_| guarded_undefined_clone) { + local_types.insert(method.params[index].id, perry_hir::types::Type::Void); + } let clamp_fn_ids: std::collections::HashSet = cross_module .clamp3_functions @@ -465,6 +310,18 @@ pub(super) fn compile_method( std::collections::HashSet::new() }; + let mut guarded_param_proofs = index_param_proofs; + if let Some(index) = guarded_undefined_param.filter(|_| guarded_undefined_clone) { + guarded_param_proofs.insert(method.params[index].id, perry_hir::types::Type::Void); + } + let mut reassigned_locals = crate::collectors::reassigned_locals(&method.body); + if let Some(index) = guarded_undefined_param.filter(|_| guarded_undefined_clone) { + // Candidate discovery already rejected every user-authored write and + // closure capture. The remaining assignment is TypeScript's lowered + // optional-parameter prologue (`undefined = undefined`), which cannot + // invalidate the wrapper's exact-value proof. + reassigned_locals.remove(&method.params[index].id); + } let mut ctx = FnCtx { func: lf, module_slug: crate::expr::native_region_slug(strings.module_prefix()), @@ -477,10 +334,10 @@ pub(super) fn compile_method( native_facts: &native_facts, locals, local_types, - proven_local_types: index_param_proofs, + proven_local_types: guarded_param_proofs, guarded_discriminant_aliases: std::collections::HashMap::new(), module_global_proven_types: &cross_module.module_global_proven_types, - reassigned_locals: crate::collectors::reassigned_locals(&method.body), + reassigned_locals, const_string_locals: std::collections::HashMap::new(), const_number_locals: std::collections::HashMap::new(), current_block: 0, @@ -1300,11 +1157,13 @@ pub(super) fn compile_method( // public symbol (and its trampoline/forwarder, if any) belongs to the // primary invocation. Emitting it again here would define that symbol // twice. - if !is_pshape_clone && !is_index_clone { + if let Some(param_index) = guarded_undefined_param.filter(|_| !guarded_undefined_clone) { + emit_guarded_undefined(llmod, method, &family_name, &llvm_name, param_index); + } else if !is_pshape_clone && !is_index_clone && !guarded_undefined_clone { if let Some(kind) = typed_public_trampoline { - emit_public_typed_method_trampoline(llmod, method, &public_llvm_name, &llvm_name, kind); + emit_public_typed(llmod, method, &public_llvm_name, &llvm_name, kind); } else if force_generic_body { - emit_public_generic_method_forwarder(llmod, method, &public_llvm_name, &llvm_name); + emit_public_generic(llmod, method, &public_llvm_name, &llvm_name); } } Ok(()) diff --git a/crates/perry-codegen/src/codegen/method_trampolines.rs b/crates/perry-codegen/src/codegen/method_trampolines.rs new file mode 100644 index 0000000000..3ab1a6c265 --- /dev/null +++ b/crates/perry-codegen/src/codegen/method_trampolines.rs @@ -0,0 +1,257 @@ +//! Stable boxed-ABI entry wrappers for specialized method bodies. + +use perry_hir::Function; + +use crate::module::LlModule; +use crate::types::{LlvmType, DOUBLE, I1, I32, I64}; + +use super::typed_abi::{ + emit_typed_arg_guard, emit_typed_arg_to_raw, typed_f64_method_name, typed_i1_method_name, + typed_i32_method_name, typed_param_reps_for_params, typed_string_method_name, + TypedFunctionTrampolineKind, TypedParamRep, +}; + +fn emit_typed_fast_value( + blk: &mut crate::block::LlBlock, + kind: TypedFunctionTrampolineKind, + typed_name: &str, + arg_names: &[String], + arg_reps: &[TypedParamRep], +) -> String { + match kind { + TypedFunctionTrampolineKind::F64 => { + let raw_args: Vec = arg_names + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) + .collect(); + let typed_args: Vec<(LlvmType, &str)> = raw_args + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) + .collect(); + blk.call(DOUBLE, typed_name, &typed_args) + } + TypedFunctionTrampolineKind::I32 => { + let raw_args: Vec = arg_names + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) + .collect(); + let typed_args: Vec<(LlvmType, &str)> = raw_args + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) + .collect(); + let raw_i32 = blk.call(I32, typed_name, &typed_args); + crate::expr::i32_to_nanbox(blk, &raw_i32) + } + TypedFunctionTrampolineKind::I1 => { + let raw_args: Vec = arg_names + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) + .collect(); + let typed_args: Vec<(LlvmType, &str)> = raw_args + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) + .collect(); + let typed_i1 = blk.call(I1, typed_name, &typed_args); + let typed_i32 = blk.zext(I1, &typed_i1, I32); + crate::expr::i32_bool_to_nanbox(blk, &typed_i32) + } + TypedFunctionTrampolineKind::StringRef => { + let raw_args: Vec = arg_names + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| emit_typed_arg_to_raw(blk, *rep, arg)) + .collect(); + let typed_args: Vec<(LlvmType, &str)> = raw_args + .iter() + .zip(arg_reps.iter()) + .map(|(arg, rep)| (rep.llvm_ty(), arg.as_str())) + .collect(); + let raw_string = blk.call(I64, typed_name, &typed_args); + blk.call(DOUBLE, "js_nanbox_string", &[(I64, &raw_string)]) + } + } +} + +pub(super) fn emit_public_typed( + llmod: &mut LlModule, + method: &Function, + public_name: &str, + generic_body_name: &str, + kind: TypedFunctionTrampolineKind, +) { + let typed_name = match kind { + TypedFunctionTrampolineKind::F64 => typed_f64_method_name(public_name), + TypedFunctionTrampolineKind::I32 => typed_i32_method_name(public_name), + TypedFunctionTrampolineKind::I1 => typed_i1_method_name(public_name), + TypedFunctionTrampolineKind::StringRef => typed_string_method_name(public_name), + }; + let arg_reps = match kind { + TypedFunctionTrampolineKind::F64 => typed_param_reps_for_params(&method.params) + .unwrap_or_else(|| vec![TypedParamRep::F64; method.params.len()]), + TypedFunctionTrampolineKind::I32 => typed_param_reps_for_params(&method.params) + .unwrap_or_else(|| vec![TypedParamRep::I32; method.params.len()]), + TypedFunctionTrampolineKind::I1 => typed_param_reps_for_params(&method.params) + .unwrap_or_else(|| vec![TypedParamRep::I1; method.params.len()]), + TypedFunctionTrampolineKind::StringRef => typed_param_reps_for_params(&method.params) + .unwrap_or_else(|| vec![TypedParamRep::StringRef; method.params.len()]), + }; + let mut params: Vec<(LlvmType, String)> = Vec::with_capacity(method.params.len() + 1); + params.push((DOUBLE, "%this_arg".to_string())); + for p in &method.params { + params.push((DOUBLE, format!("%arg{}", p.id))); + } + let arg_names: Vec = method + .params + .iter() + .map(|p| format!("%arg{}", p.id)) + .collect(); + let wf = llmod.define_function(public_name, DOUBLE, params); + let _ = wf.create_block("entry"); + + let mut guard: Option = None; + { + let blk = wf.block_mut(0).unwrap(); + for (arg, rep) in arg_names.iter().zip(arg_reps.iter()) { + let ok = emit_typed_arg_guard(blk, *rep, arg); + guard = Some(match guard { + Some(prev) => blk.and(I1, &prev, &ok), + None => ok, + }); + } + } + + let Some(guard) = guard else { + let value = emit_typed_fast_value( + wf.block_mut(0).unwrap(), + kind, + &typed_name, + &arg_names, + &arg_reps, + ); + wf.block_mut(0).unwrap().ret(DOUBLE, &value); + return; + }; + + let fast_idx = wf.num_blocks(); + let fast_label = wf.create_block("typed_method_public.fast").label.clone(); + let fallback_idx = wf.num_blocks(); + let fallback_label = wf + .create_block("typed_method_public.fallback") + .label + .clone(); + wf.block_mut(0) + .unwrap() + .cond_br(&guard, &fast_label, &fallback_label); + + let fast_value = emit_typed_fast_value( + wf.block_mut(fast_idx).unwrap(), + kind, + &typed_name, + &arg_names, + &arg_reps, + ); + wf.block_mut(fast_idx).unwrap().ret(DOUBLE, &fast_value); + + let mut call_args: Vec<(LlvmType, &str)> = Vec::with_capacity(arg_names.len() + 1); + call_args.push((DOUBLE, "%this_arg")); + for arg in &arg_names { + call_args.push((DOUBLE, arg.as_str())); + } + let fallback_value = + wf.block_mut(fallback_idx) + .unwrap() + .call(DOUBLE, generic_body_name, &call_args); + wf.block_mut(fallback_idx) + .unwrap() + .ret(DOUBLE, &fallback_value); +} + +pub(super) fn emit_public_generic( + llmod: &mut LlModule, + method: &Function, + public_name: &str, + generic_body_name: &str, +) { + let mut params: Vec<(LlvmType, String)> = Vec::with_capacity(method.params.len() + 1); + params.push((DOUBLE, "%this_arg".to_string())); + for p in &method.params { + params.push((DOUBLE, format!("%arg{}", p.id))); + } + let wf = llmod.define_function(public_name, DOUBLE, params); + let _ = wf.create_block("entry"); + let mut arg_names: Vec = Vec::with_capacity(method.params.len() + 1); + arg_names.push("%this_arg".to_string()); + for p in &method.params { + arg_names.push(format!("%arg{}", p.id)); + } + let call_args: Vec<(LlvmType, &str)> = + arg_names.iter().map(|arg| (DOUBLE, arg.as_str())).collect(); + let value = wf + .block_mut(0) + .unwrap() + .call(DOUBLE, generic_body_name, &call_args); + wf.block_mut(0).unwrap().ret(DOUBLE, &value); +} + +pub(super) fn guarded_undefined_name(base_name: &str, param_index: usize) -> String { + format!("{base_name}$undef{param_index}") +} + +/// Emit the stable boxed-ABI wrapper for an exact-`undefined` method version. +/// The optional annotation only selected the candidate; this bit comparison is +/// the runtime proof consumed by the private clone. +pub(super) fn emit_guarded_undefined( + llmod: &mut LlModule, + method: &Function, + wrapper_name: &str, + generic_body_name: &str, + param_index: usize, +) { + let clone_name = guarded_undefined_name(wrapper_name, param_index); + let mut params: Vec<(LlvmType, String)> = Vec::with_capacity(method.params.len() + 1); + params.push((DOUBLE, "%this_arg".to_string())); + for p in &method.params { + params.push((DOUBLE, format!("%arg{}", p.id))); + } + let wf = llmod.define_function(wrapper_name, DOUBLE, params); + let _ = wf.create_block("entry"); + let guarded_arg = format!("%arg{}", method.params[param_index].id); + let arg_bits = wf.block_mut(0).unwrap().bitcast_double_to_i64(&guarded_arg); + let is_undefined = + wf.block_mut(0) + .unwrap() + .icmp_eq(I64, &arg_bits, crate::nanbox::TAG_UNDEFINED_I64); + let fast_idx = wf.num_blocks(); + let fast_label = wf.create_block("undefined_method.fast").label.clone(); + let generic_idx = wf.num_blocks(); + let generic_label = wf.create_block("undefined_method.generic").label.clone(); + wf.block_mut(0) + .unwrap() + .cond_br(&is_undefined, &fast_label, &generic_label); + + let mut arg_names: Vec = Vec::with_capacity(method.params.len() + 1); + arg_names.push("%this_arg".to_string()); + for p in &method.params { + arg_names.push(format!("%arg{}", p.id)); + } + let call_args: Vec<(LlvmType, &str)> = + arg_names.iter().map(|arg| (DOUBLE, arg.as_str())).collect(); + let fast_value = wf + .block_mut(fast_idx) + .unwrap() + .call(DOUBLE, &clone_name, &call_args); + wf.block_mut(fast_idx).unwrap().ret(DOUBLE, &fast_value); + let generic_value = + wf.block_mut(generic_idx) + .unwrap() + .call(DOUBLE, generic_body_name, &call_args); + wf.block_mut(generic_idx) + .unwrap() + .ret(DOUBLE, &generic_value); +} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ca3d4b9b7d..eaffb47912 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -175,6 +175,7 @@ impl Drop for CompileProgress { } pub(crate) mod arguments; +mod artifact_context; mod artifacts; mod boxed_locals; mod closure; @@ -196,9 +197,12 @@ mod index_method_clone_tests; mod clone_suffix_tests; #[cfg(test)] mod declared_string_add_tests; +#[cfg(test)] +mod guarded_undefined_method_tests; pub(crate) mod helpers; mod method; mod method_registry; +mod method_trampolines; mod module_globals_emit; mod native_namespace_exports; #[cfg(test)] @@ -248,7 +252,8 @@ pub(crate) use typed_abi::{ TypedReceiverMethodInfo, }; -use artifacts::{emit_module_artifacts, ModuleArtifactsCtx}; +use artifact_context::ModuleArtifactsCtx; +use artifacts::emit_module_artifacts; use function::{ compile_function, compile_typed_f64_function, compile_typed_i1_function, compile_typed_i32_function, compile_typed_string_function, @@ -1759,6 +1764,26 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> }) }) .collect(); + // One bounded full-body version per eligible method. The erased optional + // annotation only nominates a candidate; the public wrapper emitted in + // `codegen/method.rs` guards the actual argument bits before the clone can + // consume a `Type::Void` proof. Keep the module-wide cap explicit so a + // source file with many optional loop filters cannot grow without bound. + let mut guarded_undefined_method_candidates: Vec<_> = hir + .classes + .iter() + .flat_map(|class| { + class.methods.iter().filter_map(move |method| { + param_guard::guarded_undefined_method_candidate(method).map(|candidate| { + ( + candidate.body_nodes, + (class.name.clone(), method.name.clone()), + candidate.param_index, + ) + }) + }) + }) + .collect(); progress.checkpoint("cross-module and typed-ABI analysis"); // Module-wide dispatch/barrier facts. Hoisted above the typed-clone @@ -1976,6 +2001,24 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } } + // Keep this first implementation non-combinatorial. Existing typed/raw + // method clone families have their own public trampolines and calling + // conventions; composing those proofs is separate work. The optional + // undefined version retains the ordinary boxed ABI throughout. + guarded_undefined_method_candidates.retain(|(_, key, _)| { + !typed_f64_methods.contains(key) + && !typed_i32_methods.contains(key) + && !typed_i1_methods.contains(key) + && !typed_string_methods.contains(key) + && !typed_f64_receiver_methods.contains_key(key) + && !nonnegative_index_methods.contains_key(key) + }); + guarded_undefined_method_candidates.sort_unstable_by(|left, right| left.cmp(right)); + let guarded_undefined_method_params = guarded_undefined_method_candidates + .into_iter() + .take(16) + .map(|(_, key, param_index)| (key, param_index)) + .collect(); let mut compiler_private_async_i32_control_locals = std::collections::HashSet::new(); let mut compiler_private_async_i1_control_locals = std::collections::HashSet::new(); crate::boxed_vars::collect_compiler_private_async_control_locals_in_stmts( @@ -2254,6 +2297,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> typed_i1_method_param_reps, typed_f64_receiver_methods, nonnegative_index_methods, + guarded_undefined_method_params, pshape_methods, pshape_tower_routable, typed_f64_closures: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index d09903049a..998c542c04 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -920,6 +920,11 @@ pub(crate) struct CrossModuleCtx { /// manufacturing calls to clone symbols their defining module never /// exported. pub nonnegative_index_methods: std::collections::HashMap<(String, String), Vec>, + /// Instance methods with one emitted exact-`undefined` parameter clone, + /// mapped to the guarded formal index. The public symbol keeps the stable + /// JSValue ABI and performs the bit-exact guard before entering the clone; + /// every other runtime value falls through to the ordinary body. + pub guarded_undefined_method_params: std::collections::HashMap<(String, String), usize>, /// Representation-selection Phase 5a: `(class, method)` pairs that have a /// generated proven-`this` clone (`collectors/proven_this.rs`). Local keys /// come from body analysis; imported keys come from an explicit capability diff --git a/crates/perry-codegen/src/codegen/param_guard.rs b/crates/perry-codegen/src/codegen/param_guard.rs index c73c121101..405a4ba10e 100644 --- a/crates/perry-codegen/src/codegen/param_guard.rs +++ b/crates/perry-codegen/src/codegen/param_guard.rs @@ -797,6 +797,181 @@ pub(crate) fn inferred_guard( }) } +pub(crate) struct GuardedUndefinedMethodCandidate { + pub param_index: usize, + pub body_nodes: usize, +} + +const MAX_GUARDED_UNDEFINED_METHOD_NODES: usize = 1_024; + +/// Select one optional parameter whose exact-`undefined` value can profitably +/// specialize an instance method body. +/// +/// TypeScript's `p?: T` is lowered as `default: Some(Expr::Undefined)` plus a +/// semantic no-op prologue (`if (p === undefined) p = undefined`). The +/// annotation is not a runtime proof, so this function only nominates a clone; +/// the public method wrapper must still compare the actual NaN-box bits with +/// `TAG_UNDEFINED` before entering it. +/// +/// The clone proof remains valid only when the parameter is immutable outside +/// that synthetic prologue and is not referenced by a nested closure. The +/// profitability predicate is deliberately narrow and bounded: the parameter +/// must guard an `if (p && ...)` inside a loop, which lets the clone erase a +/// repeated truthiness dispatch and the whole conditional call arm. +pub(crate) fn guarded_undefined_method_candidate( + method: &perry_hir::Function, +) -> Option { + use perry_hir::{CompareOp, Expr, LogicalOp, Stmt}; + + let body_nodes = super::closure_collect::count_body_nodes(&method.body); + if method.is_async + || method.is_generator + || method.params.is_empty() + || body_nodes > MAX_GUARDED_UNDEFINED_METHOD_NODES + { + return None; + } + let closure_refs = crate::expr::collect_closure_referenced_locals(&method.body); + + fn is_synthetic_undefined_default(stmt: &Stmt, id: u32) -> bool { + let Stmt::If { + condition: + Expr::Compare { + op: CompareOp::Eq, + left, + right, + }, + then_branch, + else_branch: None, + } = stmt + else { + return false; + }; + let compares_undefined = matches!( + (left.as_ref(), right.as_ref()), + (Expr::LocalGet(local), Expr::Undefined) + | (Expr::Undefined, Expr::LocalGet(local)) if *local == id + ); + compares_undefined + && matches!( + then_branch.as_slice(), + [Stmt::Expr(Expr::LocalSet(local, value))] + if *local == id && matches!(value.as_ref(), Expr::Undefined) + ) + } + + fn expr_has_guard(expr: &Expr, id: u32) -> bool { + if matches!( + expr, + Expr::Logical { + op: LogicalOp::And, + left, + .. + } if matches!(left.as_ref(), Expr::LocalGet(local) if *local == id) + ) { + return true; + } + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + found |= expr_has_guard(child, id); + }); + found + } + + fn body_has_loop_guard(stmts: &[Stmt], id: u32, in_loop: bool) -> bool { + stmts.iter().any(|stmt| match stmt { + Stmt::If { + condition, + then_branch, + else_branch, + } => { + (in_loop && expr_has_guard(condition, id)) + || body_has_loop_guard(then_branch, id, in_loop) + || else_branch + .as_deref() + .is_some_and(|body| body_has_loop_guard(body, id, in_loop)) + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + expr_has_guard(condition, id) || body_has_loop_guard(body, id, true) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_some_and(|stmt| { + body_has_loop_guard(std::slice::from_ref(stmt), id, in_loop) + }) || condition + .as_ref() + .is_some_and(|expr| expr_has_guard(expr, id)) + || update.as_ref().is_some_and(|expr| expr_has_guard(expr, id)) + || body_has_loop_guard(body, id, true) + } + Stmt::Try { + body, + catch, + finally, + } => { + body_has_loop_guard(body, id, in_loop) + || catch + .as_ref() + .is_some_and(|catch| body_has_loop_guard(&catch.body, id, in_loop)) + || finally + .as_deref() + .is_some_and(|body| body_has_loop_guard(body, id, in_loop)) + } + Stmt::Switch { + discriminant, + cases, + } => { + (in_loop && expr_has_guard(discriminant, id)) + || cases + .iter() + .any(|case| body_has_loop_guard(&case.body, id, in_loop)) + } + Stmt::Labeled { body, .. } => { + body_has_loop_guard(std::slice::from_ref(body.as_ref()), id, in_loop) + } + Stmt::Expr(expr) | Stmt::Throw(expr) => in_loop && expr_has_guard(expr, id), + Stmt::Return(Some(expr)) => in_loop && expr_has_guard(expr, id), + Stmt::Let { + init: Some(expr), .. + } => in_loop && expr_has_guard(expr, id), + Stmt::Return(None) + | Stmt::Let { init: None, .. } + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => false, + }) + } + + method.params.iter().enumerate().find_map(|(index, param)| { + if param.is_rest + || param.arguments_object.is_some() + || !matches!(param.default, Some(Expr::Undefined)) + || closure_refs.contains(¶m.id) + { + return None; + } + let has_real_reassignment = method.body.iter().any(|stmt| { + !is_synthetic_undefined_default(stmt, param.id) + && crate::collectors::reassigned_locals(std::slice::from_ref(stmt)) + .contains(¶m.id) + }); + (!has_real_reassignment && body_has_loop_guard(&method.body, param.id, false)).then_some( + GuardedUndefinedMethodCandidate { + param_index: index, + body_nodes, + }, + ) + }) +} + /// Whether the current function body can suspend after its entry guard. /// `walk_expr_children` intentionally does not enter nested closure bodies; /// those execute under their own entry contracts and must not disqualify the diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs index c5df31e195..87fac47328 100644 --- a/crates/perry-codegen/src/collectors/proven_this.rs +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -794,9 +794,10 @@ mod tests { // Naming + emission + the two proven call sites. `string_pool.rs` // (which emits `js_register_class_method`) is deliberately ABSENT: // the vtable must only ever hold the public symbol. - let allowed: [&str; 7] = [ + let allowed: [&str; 8] = [ "collectors/proven_this.rs", // this test "collectors/proven_this_routing_tests.rs", // routing IR ratchet + "codegen/guarded_undefined_method_tests.rs", // wrapper IR assertions "codegen/typed_abi.rs", // name helper "codegen/method.rs", // clone emission "codegen/artifacts.rs", // emission driver diff --git a/crates/perry-codegen/src/stmt/if_stmt.rs b/crates/perry-codegen/src/stmt/if_stmt.rs index 8825774a8e..0e78c40c8f 100644 --- a/crates/perry-codegen/src/stmt/if_stmt.rs +++ b/crates/perry-codegen/src/stmt/if_stmt.rs @@ -93,6 +93,14 @@ fn merge_native_arena_owner_aliases(ctx: &mut FnCtx<'_>, exits: &[NativeArenaOwn fn try_const_fold_condition(ctx: &FnCtx<'_>, condition: &perry_hir::Expr) -> Option { use perry_hir::{CompareOp, Expr, LogicalOp}; match condition { + Expr::LocalGet(id) + if matches!( + ctx.stable_local_type_proof(id), + Some(perry_hir::types::Type::Void) + ) => + { + Some(false) + } Expr::Compare { op, left, right } => { // Try to extract a known constant from one side and a literal // from the other. diff --git a/crates/perry-transform/src/async_to_generator_tests.rs b/crates/perry-transform/src/async_to_generator_tests.rs index ca2224b914..b3d44ad1cf 100644 --- a/crates/perry-transform/src/async_to_generator_tests.rs +++ b/crates/perry-transform/src/async_to_generator_tests.rs @@ -616,7 +616,10 @@ fn async_generator_linearizes_every_await_position() { vec![Stmt::Try { body: vec![y(Expr::Integer(0))], catch: None, - finally: Some(vec![y(Expr::Integer(8)), Stmt::Expr(await_(Expr::Integer(9)))]), + finally: Some(vec![ + y(Expr::Integer(8)), + Stmt::Expr(await_(Expr::Integer(9))), + ]), }], ), ( diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index 8193f56930..3660418fdb 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -1017,8 +1017,7 @@ pub fn transform_generator_function_with_extra_captures( // mirrors how `.next()`/`.throw()` already drive a yielding // finally. `wrap_generator_resume_body` clears `executing` before // this return, so `__agstep`'s re-entrancy guard passes. - let agstep_local_id = - agstep_id.expect("agstep_id is set for async generators"); + let agstep_local_id = agstep_id.expect("agstep_id is set for async generators"); return_resume_body.push(Stmt::Return(Some(Expr::AsyncGenResume { step_closure: Box::new(Expr::LocalGet(agstep_local_id)), value: Box::new(Expr::Undefined), diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 4eb69517c2..a78f912533 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -337,6 +337,14 @@ "classification": "runtime-validated", "reason": "The numeric annotation admits a candidate clone; the preheader checks the accumulator's current Number tag and the fact is scoped to a store-free, numeric-preserving fast clone." }, + { + "path": "crates/perry-codegen/src/stmt/if_stmt.rs", + "function": "try_const_fold_condition", + "access": "stable_local_type_proof", + "count": 1, + "classification": "runtime-validated", + "reason": "A falsy-local fold consumes only the private method clone's proof: its public wrapper bit-compares the live argument with TAG_UNDEFINED, candidate discovery rejects user writes and closure capture, and the proof API rejects every remaining reassigned binding." + }, { "path": "crates/perry-codegen/src/stmt/if_stmt.rs", "function": "lower_if", diff --git a/test-files/test_guarded_undefined_method_param.ts b/test-files/test_guarded_undefined_method_param.ts new file mode 100644 index 0000000000..e720180290 --- /dev/null +++ b/test-files/test_guarded_undefined_method_param.ts @@ -0,0 +1,84 @@ +// Perry exposes this hook; ordinary Node does not. The forced-evacuation test +// uses it to move live state from inside the specialized loop without changing +// the parity fixture's observable output. +const collect = (globalThis as unknown as { gc?: () => void }).gc; + +class Scanner { + scan(values: number[], filter?: (value: number) => boolean): number { + let sum = 0; + for (let index = 0; index < values.length; index++) { + const value = values[index]!; + if (index === 1 && collect) collect(); + if (filter && !filter(value)) continue; + sum += value; + } + return sum; + } + + scanReassigned(values: number[], filter?: (value: number) => boolean): number { + filter = (value) => value > 1; + let sum = 0; + for (const value of values) { + if (filter && !filter(value)) continue; + sum += value; + } + return sum; + } + + scanCaptured(values: number[], filter?: (value: number) => boolean): number { + const currentFilter = () => filter; + let sum = 0; + for (const value of values) { + const current = currentFilter(); + if (current && !current(value)) continue; + sum += value; + } + return sum; + } + + scanDefault( + values: number[], + filter: (value: number) => boolean = (value) => value > 1, + ): number { + let sum = 0; + for (const value of values) { + if (filter && !filter(value)) continue; + sum += value; + } + return sum; + } +} + +const values = [1, 2, 3]; +const scanner = new Scanner(); +let calls = 0; +const even = (value: number): boolean => { + calls++; + return value % 2 === 0; +}; + +console.log("omitted", scanner.scan(values), calls); +console.log("undefined", scanner.scan(values, undefined), calls); +console.log("function", scanner.scan(values, even), calls); +console.log("null", (scanner.scan as any).call(scanner, values, null), calls); +console.log("false", (scanner.scan as any).call(scanner, values, false), calls); +console.log("zero", (scanner.scan as any).call(scanner, values, 0), calls); +console.log("empty", (scanner.scan as any).call(scanner, values, ""), calls); + +try { + (scanner.scan as any).call(scanner, values, {}); + console.log("object", false); +} catch (error) { + console.log("object", error instanceof TypeError); +} + +console.log("reassigned", scanner.scanReassigned(values, undefined)); +console.log("captured", scanner.scanCaptured(values, even), calls); +console.log("default", scanner.scanDefault(values, undefined)); + +const own = new Scanner() as any; +own.scan = () => 77; +console.log("own override", own.scan(values, undefined)); + +(Scanner.prototype as any).scan = () => 88; +console.log("prototype override", new Scanner().scan(values, undefined));