From 708f71fd1b9bc78acba13e42c32f036b7cd55459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 27 Jul 2026 15:40:12 +0200 Subject: [PATCH 1/8] =?UTF-8?q?perf(codegen):=20representation-selection?= =?UTF-8?q?=20Phase=202=20=E2=80=94=20specialized=20calling=20convention?= =?UTF-8?q?=20(bounded=20monomorphization)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-body specialized entries via the real compile_function parameterized on a call-site-derived rep tuple (Boxed/I32/F64/TaPtr), static guard-free Tier A dispatch for by-construction proofs, guarded Tier B for declaration-only proofs, proven-view checked element access, and the ctx-free spec_abi_sites pre-pass. Implements Phase 2 of docs/representation-selection-rfc.md. Claude-Session: https://claude.ai/code/session_01UGDwjukzhowLDJYsMPFwMv --- crates/perry-codegen/src/codegen/closure.rs | 3 + crates/perry-codegen/src/codegen/entry.rs | 6 + crates/perry-codegen/src/codegen/function.rs | 182 ++++- crates/perry-codegen/src/codegen/method.rs | 6 + crates/perry-codegen/src/codegen/mod.rs | 175 +++++ crates/perry-codegen/src/codegen/opts.rs | 8 + crates/perry-codegen/src/codegen/spec_abi.rs | 317 +++++++++ crates/perry-codegen/src/codegen/typed_abi.rs | 15 + .../src/collectors/clamp_detect.rs | 16 +- crates/perry-codegen/src/collectors/mod.rs | 4 + .../src/collectors/spec_abi_sites.rs | 659 ++++++++++++++++++ .../src/collectors/spec_abi_sites/tests.rs | 265 +++++++ .../perry-codegen/src/expr/buffer_access.rs | 2 +- crates/perry-codegen/src/expr/buffer_views.rs | 4 + crates/perry-codegen/src/expr/index_get.rs | 8 + crates/perry-codegen/src/expr/index_set.rs | 15 + crates/perry-codegen/src/expr/mod.rs | 28 +- .../src/expr/proven_view_access.rs | 363 ++++++++++ crates/perry-codegen/src/expr/range_facts.rs | 11 +- .../perry-codegen/src/lower_call/func_ref.rs | 256 ++++++- .../perry-codegen/src/native_value/buffer.rs | 9 + crates/perry-codegen/src/stmt/let_stmt.rs | 49 +- crates/perry-codegen/src/stmt/mod.rs | 13 + .../src/commands/compile/object_cache.rs | 15 + .../object_cache/object_cache_tests.rs | 3 + docs/representation-selection-rfc.md | 13 +- .../test_gap_specabi_polymorphic_coexist.ts | 42 ++ test-files/test_gap_specabi_reassign.ts | 16 + .../test_gap_specabi_recursion_escape.ts | 23 + test-files/test_gap_specabi_view_detach.ts | 29 + 30 files changed, 2524 insertions(+), 31 deletions(-) create mode 100644 crates/perry-codegen/src/codegen/spec_abi.rs create mode 100644 crates/perry-codegen/src/collectors/spec_abi_sites.rs create mode 100644 crates/perry-codegen/src/collectors/spec_abi_sites/tests.rs create mode 100644 crates/perry-codegen/src/expr/proven_view_access.rs create mode 100644 test-files/test_gap_specabi_polymorphic_coexist.ts create mode 100644 test-files/test_gap_specabi_reassign.ts create mode 100644 test-files/test_gap_specabi_recursion_escape.ts create mode 100644 test-files/test_gap_specabi_view_detach.ts diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 22b2388f22..4054a51fed 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -866,6 +866,9 @@ pub(super) fn compile_closure( local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + spec_abi_functions: &cross_module.spec_abi_functions, + spec_ta_bindings: &cross_module.spec_ta_bindings, + spec_ta_ready: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index fc065753ee..458c6bb516 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -768,6 +768,9 @@ pub(super) fn compile_module_entry( // import/init machinery; the win lives in function bodies). repsel_context_allows_canonical_i32: false, repsel_closure_ref_locals: std::collections::HashSet::new(), + spec_abi_functions: &cross_module.spec_abi_functions, + spec_ta_bindings: &cross_module.spec_ta_bindings, + spec_ta_ready: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: main_native_facts.index_used_locals(), strictly_i32_bounded_locals: main_native_facts.strictly_i32_bounded_locals(), @@ -1379,6 +1382,9 @@ pub(super) fn compile_module_entry( // import/init machinery; the win lives in function bodies). repsel_context_allows_canonical_i32: false, repsel_closure_ref_locals: std::collections::HashSet::new(), + spec_abi_functions: &cross_module.spec_abi_functions, + spec_ta_bindings: &cross_module.spec_ta_bindings, + spec_ta_ready: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: init_native_facts.index_used_locals(), strictly_i32_bounded_locals: init_native_facts.strictly_i32_bounded_locals(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 0c962f3076..4fd089dede 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -17,6 +17,10 @@ use crate::types::{LlvmType, DOUBLE, I1, I32, I64, I8, PTR}; use super::helpers::shadow_stack_enabled; use super::helpers::{inline_hot_small_enabled, inline_hot_small_size_cap, INLINE_HOT_SMALL_MIN}; use super::opts::CrossModuleCtx; +use super::spec_abi::{ + spec_function_name, spec_rep_llvm_ty, spec_ta_kind_class_name, spec_ta_kind_elem_width, + SpecFnPlan, +}; 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, @@ -337,12 +341,20 @@ pub(super) fn compile_function( closure_rest_params: &HashMap, cross_module: &CrossModuleCtx, typed_public_trampoline: Option, + spec_entry: Option<&SpecFnPlan>, ) -> Result<()> { let public_llvm_name = func_names .get(&f.id) .cloned() .ok_or_else(|| anyhow!("function name not resolved for {}", f.name))?; - let llvm_name = if typed_public_trampoline.is_some() { + let llvm_name = if let Some(plan) = spec_entry { + // Spec entries are an ADDITIONAL internal symbol next to the ordinary + // public body — never combined with the typed_abi trampoline scheme + // (mutual exclusion is enforced at plan selection). + debug_assert!(typed_public_trampoline.is_none()); + debug_assert_eq!(plan.reps.len(), f.params.len()); + spec_function_name(&public_llvm_name, &plan.reps) + } else if typed_public_trampoline.is_some() { generic_function_body_name(&public_llvm_name) } else { public_llvm_name.clone() @@ -350,17 +362,27 @@ pub(super) fn compile_function( // Phase A assumes all user-function params are `double`. Parameter // registers are named `%arg{LocalId}` so the body can store them into - // alloca slots keyed by the same HIR LocalId. - let params: Vec<(LlvmType, String)> = f - .params - .iter() - .map(|p| (DOUBLE, format!("%arg{}", p.id))) - .collect(); + // alloca slots keyed by the same HIR LocalId. Specialized entries + // (representation-selection Phase 2) instead type each parameter register + // by its rep — raw i32, raw f64, or raw typed-array header pointer (i64). + let params: Vec<(LlvmType, String)> = match spec_entry { + Some(plan) => f + .params + .iter() + .zip(plan.reps.iter()) + .map(|(p, rep)| (spec_rep_llvm_ty(*rep), format!("%arg{}", p.id))) + .collect(), + None => f + .params + .iter() + .map(|p| (DOUBLE, format!("%arg{}", p.id))) + .collect(), + }; let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; let lf = llmod.define_function(&llvm_name, DOUBLE, params); - if typed_public_trampoline.is_some() { + if typed_public_trampoline.is_some() || spec_entry.is_some() { lf.linkage = "internal".to_string(); } @@ -422,11 +444,55 @@ pub(super) fn compile_function( // Store each param into an alloca slot, collecting LocalId → slot // mappings. We release the &mut LlBlock at scope end before handing // the function over to the FnCtx lowering pass. + // + // Specialized entries bind per-rep: + // - `I32` → straight into a canonical i32 slot (Phase 1 `SlotRep` + // mechanism; the raw `%arg` i32 stores with ZERO conversions and no + // double slot / no shadow binding — a number is never a GC root). + // Plan selection guarantees the param is never reassigned and never + // closure-referenced, so the canonical slot is trivially sound. + // - `TaPtr` → NaN-box the raw header pointer ONCE into the ordinary + // boxed double slot (all generic body paths stay correct) and keep the + // shadow-slot GC root binding — the header must stay live (typed-array + // storage never MOVES, but an unrooted header could still be swept). + // - `Boxed`/`F64` → exactly today's binding (a raw f64 IS its box). + let mut spec_i32_param_slots: HashMap = HashMap::new(); let locals: HashMap = { let blk = lf.block_mut(0).unwrap(); let mut map = HashMap::new(); - for p in &f.params { + for (idx, p) in f.params.iter().enumerate() { let arg_name = format!("%arg{}", p.id); + match spec_entry.map(|plan| plan.reps[idx]) { + Some(crate::collectors::SpecParamRep::I32) => { + if crate::expr::canonical_i32_locals_enabled() { + let slot = blk.alloca(I32); + blk.store(I32, &arg_name, &slot); + spec_i32_param_slots.insert(p.id, slot); + } else { + // Phase-1 kill switch: keep the boxed protocol; one + // conversion at entry instead of one per call site. + let as_f64 = blk.sitofp(I32, &arg_name, DOUBLE); + let slot = blk.alloca(DOUBLE); + blk.store(DOUBLE, &as_f64, &slot); + map.insert(p.id, slot); + } + continue; + } + Some(crate::collectors::SpecParamRep::TaPtr { .. }) => { + let boxed = crate::expr::nanbox_pointer_inline(blk, &arg_name); + let slot = blk.alloca(DOUBLE); + blk.store(DOUBLE, &boxed, &slot); + if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() { + blk.call_void( + "js_shadow_slot_bind", + &[(I32, &slot_idx.to_string()), (PTR, &slot)], + ); + } + map.insert(p.id, slot); + continue; + } + _ => {} + } let slot = super::arguments::store_param_slot(blk, p, &boxed_vars, &arg_name); if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() { blk.call_void( @@ -450,6 +516,28 @@ pub(super) fn compile_function( for p in &f.params { local_types.insert(p.id, p.ty.clone()); } + // Specialized entry: stamp the proven reps over the (usually `Any`) + // declared types BEFORE the native-fact collectors run, so every type + // predicate (int-valued locals, typed-array receiver classification, + // integer index proofs) sees the by-construction proof. Sound because + // plan selection demoted any reassigned/closure-referenced param to + // `Boxed` — a stamped param provably holds this rep for the whole body. + if let Some(plan) = spec_entry { + for (p, rep) in f.params.iter().zip(plan.reps.iter()) { + match rep { + crate::collectors::SpecParamRep::I32 => { + local_types.insert(p.id, perry_hir::types::Type::Int32); + } + crate::collectors::SpecParamRep::TaPtr { kind, .. } => { + if let Some(class) = spec_ta_kind_class_name(*kind) { + local_types + .insert(p.id, perry_hir::types::Type::Named(class.to_string())); + } + } + crate::collectors::SpecParamRep::Boxed | crate::collectors::SpecParamRep::F64 => {} + } + } + } // Pre-walk: which locals need to be boxed? A local is boxed when // it's captured by a closure AND written by someone (either the @@ -590,10 +678,18 @@ pub(super) fn compile_function( masked_region_scalar_locals: std::collections::HashSet::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), class_field_loop_facts: Vec::new(), - i32_counter_slots: HashMap::new(), - local_slot_reps: HashMap::new(), + // Specialized entries seed the canonical-i32 registry with their raw + // i32 params (empty otherwise — identical to the pre-phase behavior). + local_slot_reps: spec_i32_param_slots + .keys() + .map(|id| (*id, crate::expr::SlotRep::I32)) + .collect(), + i32_counter_slots: spec_i32_param_slots, repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + spec_abi_functions: &cross_module.spec_abi_functions, + spec_ta_bindings: &cross_module.spec_ta_bindings, + spec_ta_ready: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), @@ -735,10 +831,74 @@ pub(super) fn compile_function( alias: AliasState::Unknown, length_source: Some(LengthSource::Unknown), native_owned: None, + // Declared-type hoist only — the construction form is unknown, + // so no inline-storage proof. + storage_inline_proven: false, }, ); } + // Representation-selection Phase 2: bind each `TaPtr` param as a PROVEN + // BufferViewSlot with the data pointer hoisted ONCE at entry (modeled on + // the Buffer-param hoist above). The call-site pre-pass proved a fresh + // non-view construction, so kind/data/length are fixed for the array's + // lifetime: element accesses lower through the strong bare-load machinery + // (`ta_int_elem_load_is_i32_provable`, `lower_typed_array_load`, the + // proven-view checked tier) with bounds checks against the entry length — + // and NEVER through the per-site guarded fast paths (`ta_param_f64_read` + // skips receivers with a registered view slot). GC note: the header is + // rooted via the boxed param slot's shadow binding above; the hoisted + // data pointer stays valid because typed-array storage is non-movable + // (`gc/types.rs`: `GC_TYPE_TYPED_ARRAY`/`GC_TYPE_BUFFER` `movable: false`) + // and a non-view typed array cannot be detached or resized. + if let Some(plan) = spec_entry { + for (p, rep) in f.params.iter().zip(plan.reps.iter()) { + let crate::collectors::SpecParamRep::TaPtr { kind, const_len } = rep else { + continue; + }; + let Some((elem, width)) = spec_ta_kind_elem_width(*kind) else { + continue; + }; + let Some(param_slot) = ctx.locals.get(&p.id).cloned() else { + continue; + }; + let blk = ctx.block(); + let arg_val = blk.load(DOUBLE, ¶m_slot); + let handle = crate::expr::unbox_to_i64(blk, &arg_val); + let handle_ptr = blk.inttoptr(I64, &handle); + // TypedArrayHeader layout: length at +0, data at +16. + let data_ptr = blk.gep(I8, &handle_ptr, &[(I32, "16")]); + let data_slot = ctx.func.alloca_entry(PTR); + ctx.block().store(PTR, &data_ptr, &data_slot); + let scope_idx = ctx.buffer_alias_base + ctx.buffer_data_slots.len() as u32; + ctx.buffer_data_slots + .insert(p.id, (data_slot.clone(), scope_idx)); + ctx.buffer_view_slots.insert( + p.id, + BufferViewSlot { + data_slot, + length_slot: None, + scope_idx: Some(scope_idx), + elem, + element_width_bytes: width, + index_unit: BufferIndexUnit::Element, + view_byte_offset: Some(0), + length_offset_from_data: -16, + // Distinct `TaPtr` args are distinct fresh allocations + // (the Tier A call-site match rejects duplicate locals), + // so pairwise noalias holds by construction. + alias: AliasState::NoAliasProven, + length_source: Some(match const_len { + Some(len) => LengthSource::Constant(*len), + None => LengthSource::Unknown, + }), + native_owned: None, + storage_inline_proven: true, + }, + ); + } + } + if f.is_async { stmt::lower_async_rejecting_top_level_stmts(&mut ctx, &f.body) .with_context(|| format!("lowering async body of '{}'", f.name))?; diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index a9fd211062..c1bae17aaa 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -489,6 +489,9 @@ pub(super) fn compile_method( local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + spec_abi_functions: &cross_module.spec_abi_functions, + spec_ta_bindings: &cross_module.spec_ta_bindings, + spec_ta_ready: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), @@ -1506,6 +1509,9 @@ pub(super) fn compile_static_method( local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + spec_abi_functions: &cross_module.spec_abi_functions, + spec_ta_bindings: &cross_module.spec_ta_bindings, + spec_ta_ready: std::collections::HashSet::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), strictly_i32_bounded_locals: native_facts.strictly_i32_bounded_locals(), diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 307e915e12..8597fbe95c 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -55,6 +55,7 @@ mod method; mod method_registry; mod module_globals_emit; mod opts; +mod spec_abi; mod string_pool; mod typed_abi; @@ -68,6 +69,7 @@ pub use opts::{ AppMetadata, CompileOptions, FpContractMode, ImportedClass, NamespaceEntry, NamespaceEntryKind, }; pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; +pub(crate) use spec_abi::{spec_abi_enabled, spec_function_name, SpecDispatch, SpecFnPlan}; pub(crate) use typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_closure_body_name, generic_function_body_name, generic_method_body_name, typed_f64_closure_name, @@ -1577,6 +1579,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .filter(|f| crate::collectors::returns_i32_identity_arg(f)) .map(|f| f.id) .collect(), + // Phase 2 spec-ABI plans are selected AFTER the i64-specialization + // pass (mutual exclusion), below; start empty here. + spec_abi_functions: std::collections::HashMap::new(), + spec_ta_bindings: std::collections::HashMap::new(), typed_f64_functions, typed_i32_functions, typed_i1_functions, @@ -2079,6 +2085,143 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .typed_i1_function_param_reps .retain(|id, _| !i64_specialized.contains(id)); + // ---- Representation-selection Phase 2: specialized-ABI plan selection. + // Runs AFTER the i64-specialization pass and the typed_abi clone sets so + // mutual exclusion is decidable; the entries themselves are emitted below + // in the pre-public loop. Bounded: one entry per function (the dominant + // tuple), `PERRY_SPECIALIZED_ABI_MAX` per module. + if spec_abi::spec_abi_enabled() { + let spec_facts = crate::collectors::collect_spec_abi_facts(hir); + let spec_budget = spec_abi::spec_abi_max(); + let mut spec_emitted = 0usize; + for f in &hir.functions { + let Some(sites) = spec_facts.call_sites.get(&f.id) else { + continue; + }; + let mut reject = |reason: typed_abi::TypedCloneRejectionReason, + records: &mut Vec| { + record_typed_clone_rejection( + records, + f.name.clone(), + "spec_abi_entry_decision", + reason, + vec![ + "typed_clone_kind=spec_abi_entry".to_string(), + format!("function_id={}", f.id), + format!("symbol={}", f.name), + ], + ); + }; + if f.is_async || f.is_generator || f.was_plain_async { + reject( + typed_abi::TypedCloneRejectionReason::AsyncOrGenerator, + &mut typed_clone_rejection_records, + ); + continue; + } + if !f.captures.is_empty() { + reject( + typed_abi::TypedCloneRejectionReason::Captures, + &mut typed_clone_rejection_records, + ); + continue; + } + if f.params.iter().any(|p| p.default.is_some()) { + reject( + typed_abi::TypedCloneRejectionReason::ParamDefault, + &mut typed_clone_rejection_records, + ); + continue; + } + if f.params.iter().any(|p| p.is_rest) { + reject( + typed_abi::TypedCloneRejectionReason::RestParam, + &mut typed_clone_rejection_records, + ); + continue; + } + if f.params.iter().any(|p| p.arguments_object.is_some()) + || func_synthetic_arguments.contains(&f.id) + { + reject( + typed_abi::TypedCloneRejectionReason::ArgumentsObject, + &mut typed_clone_rejection_records, + ); + continue; + } + if cross_module.funcs_reading_dynamic_this.contains(&f.id) { + reject( + typed_abi::TypedCloneRejectionReason::SpecReadsDynamicThis, + &mut typed_clone_rejection_records, + ); + continue; + } + if i64_specialized.contains(&f.id) { + reject( + typed_abi::TypedCloneRejectionReason::I64Specialized, + &mut typed_clone_rejection_records, + ); + continue; + } + if cross_module.typed_f64_functions.contains(&f.id) + || cross_module.typed_i32_functions.contains(&f.id) + || cross_module.typed_i1_functions.contains(&f.id) + || cross_module.typed_string_functions.contains(&f.id) + { + reject( + typed_abi::TypedCloneRejectionReason::SpecTypedCloneOverlap, + &mut typed_clone_rejection_records, + ); + continue; + } + // Callee-side demotion: params the raw ABI cannot accept keep the + // boxed protocol (reassigned params would stale the entry-bound + // proofs; closure-referenced params feed the capture machinery). + let closure_refs = crate::expr::collect_closure_referenced_locals(&f.body); + let demoted: Vec = f + .params + .iter() + .map(|p| { + crate::collectors::local_is_reassigned(&f.body, p.id) + || closure_refs.contains(&p.id) + }) + .collect(); + let plan = match spec_abi::select_dominant_tuple(sites, f.params.len(), &demoted) { + Some((reps, _matching_sites)) => SpecFnPlan { + reps, + dispatch: SpecDispatch::Static, + }, + None => { + // Tier B: declaration-derived tuple, runtime-guarded + // dispatch. Only viable with ≥1 declared-Int32 param. + let reps = spec_abi::declaration_tuple(&f.params, &demoted); + if spec_abi::spec_tuple_is_viable(&reps) { + SpecFnPlan { + reps, + dispatch: SpecDispatch::Guarded, + } + } else { + reject( + typed_abi::TypedCloneRejectionReason::SpecTupleUnproven, + &mut typed_clone_rejection_records, + ); + continue; + } + } + }; + if spec_emitted >= spec_budget { + reject( + typed_abi::TypedCloneRejectionReason::SpecBudgetExceeded, + &mut typed_clone_rejection_records, + ); + continue; + } + spec_emitted += 1; + cross_module.spec_abi_functions.insert(f.id, plan); + } + cross_module.spec_ta_bindings = spec_facts.ta_bindings; + } + // Emit internal typed-f64 clones before their public/generic wrappers. The // public wrapper keeps the JSValue ABI; it and direct proven numeric call // sites can call the internal clone. @@ -2123,6 +2266,37 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .with_context(|| format!("lowering typed-string clone for function '{}'", f.name))?; } + // Representation-selection Phase 2: emit full-body specialized entries + // (`{public}__spec_...`, internal linkage) before the public bodies. Same + // real `compile_function`, parameterized on the plan's rep tuple. + for f in &hir.functions { + let Some(plan) = cross_module.spec_abi_functions.get(&f.id).cloned() else { + continue; + }; + compile_function( + &mut llmod, + f, + &func_names, + &mut 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, + Some(&plan), + ) + .with_context(|| format!("lowering specialized entry for function '{}'", f.name))?; + } + // Lower each user function into the module (skip i64-specialized ones). for f in &hir.functions { if i64_specialized.contains(&f.id) { @@ -2158,6 +2332,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &closure_rest_params, &cross_module, typed_public_trampoline, + None, ) .with_context(|| format!("lowering function '{}'", f.name))?; } diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index d96b784407..d3b5c81d59 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -737,6 +737,14 @@ pub(crate) struct CrossModuleCtx { pub returns_int_functions: std::collections::HashSet, /// Single-argument integer helpers that return the argument coerced to i32. pub i32_identity_functions: std::collections::HashSet, + /// Representation-selection Phase 2 (`codegen/spec_abi.rs`): FuncId → + /// specialization plan for functions with an emitted full-body specialized + /// entry (internal linkage, named by `spec_function_name`). Mutually + /// exclusive with the typed_abi clone families and `i64_specialized`. + pub spec_abi_functions: std::collections::HashMap, + /// Phase 2 pre-pass: LocalIds proven to permanently hold one specific + /// non-view typed array (see `collectors/spec_abi_sites.rs`). + pub spec_ta_bindings: std::collections::HashMap, /// User functions that have a generated internal typed-f64 clone. The /// public wrapper keeps the JSValue ABI; direct numeric call sites may call /// the clone. diff --git a/crates/perry-codegen/src/codegen/spec_abi.rs b/crates/perry-codegen/src/codegen/spec_abi.rs new file mode 100644 index 0000000000..379e51c29e --- /dev/null +++ b/crates/perry-codegen/src/codegen/spec_abi.rs @@ -0,0 +1,317 @@ +//! Representation-selection Phase 2 (RFC `docs/representation-selection-rfc.md` +//! §5.4): the specialized calling convention — bounded monomorphization. +//! +//! Generalizes the existing three-symbol typed_abi scheme in three directions: +//! +//! 1. **Full-body specialized entries.** The specialized clone is compiled by +//! the REAL `compile_function` parameterized on a representation tuple +//! (`SpecParamRep` per param) — loops, branches, calls, the unrolled +//! bcryptjs `_encipher` all lower through the ordinary statement lowerer. +//! The public boxed entry always exists and stays the permanent ABI. +//! 2. **`TaPtr` params.** A proven typed-array param binds at entry as a +//! proven `BufferViewSlot` (data pointer + length hoisted ONCE from the +//! header — sound because typed-array storage never moves and a non-view +//! typed array cannot be detached or resized), so element accesses lower +//! through the strong bare-load machinery with bounds checks against the +//! entry-hoisted length — NEVER through the per-site guarded fast paths +//! (measured to LOSE on unrolled bodies: 834 → 2732 ms). +//! 3. **Call-site-driven tuples + static dispatch.** The rep tuple comes from +//! the `collectors::spec_abi_sites` pre-pass (call sites, not declarations), +//! and statically-proven sites call the specialized symbol DIRECTLY — no +//! guard, no diamond, no phi. Declaration-only proofs keep the existing +//! guarded-diamond shape (`SpecDispatch::Guarded`). +//! +//! Specialized symbols are `internal` and reachable ONLY from same-module +//! direct `FuncRef` call sites — closures, wrappers, exports, and every +//! indirect path resolve to the public boxed symbol (asserted by +//! `spec_abi_symbol_reachability` in the tests module). + +use std::collections::HashMap; + +pub(crate) use crate::collectors::SpecParamRep; +use crate::types::{LlvmType, DOUBLE, I32, I64}; + +/// `PERRY_SPECIALIZED_ABI` gate. Default on; `0`/`off`/`false` disables the +/// whole phase (no specialized entries, no static dispatch). Keyed into the +/// object cache (`object_cache.rs`) so A/B arms never share objects. +pub(crate) fn spec_abi_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + !matches!( + std::env::var("PERRY_SPECIALIZED_ABI").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// `PERRY_SPECIALIZED_ABI_MAX`: module-wide cap on emitted specialized +/// entries (anti-bloat budget). Default 64. Also object-cache-keyed. +pub(crate) fn spec_abi_max() -> usize { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + std::env::var("PERRY_SPECIALIZED_ABI_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(64) + }) +} + +/// How proven call sites reach the specialized entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpecDispatch { + /// Tier A: every non-Boxed slot proven BY CONSTRUCTION at the call site — + /// direct `call @{name}__spec_...` with raw args, no guard. + Static, + /// Tier B: reps proven only by declared types — the call site keeps the + /// runtime-guarded diamond (guard → spec entry / boxed fallback → phi). + Guarded, +} + +/// The per-function specialization plan threaded through `CrossModuleCtx`. +#[derive(Debug, Clone)] +pub(crate) struct SpecFnPlan { + pub reps: Vec, + pub dispatch: SpecDispatch, +} + +impl SpecFnPlan { + /// Phase-2 budget: exactly ONE specialized entry per function (the + /// dominant tuple). Kept as an explicit constant so raising it later is a + /// knob, not a rewrite. + pub(crate) const MAX_ENTRIES_PER_FUNCTION: usize = 1; +} + +/// LLVM parameter type for a rep slot. +pub(crate) fn spec_rep_llvm_ty(rep: SpecParamRep) -> LlvmType { + match rep { + SpecParamRep::Boxed | SpecParamRep::F64 => DOUBLE, + SpecParamRep::I32 => I32, + SpecParamRep::TaPtr { .. } => I64, + } +} + +/// Specialized-entry symbol: `{public}__spec_