diff --git a/changelog.d/6905-repsel-p2-specialized-abi.md b/changelog.d/6905-repsel-p2-specialized-abi.md new file mode 100644 index 0000000000..ce832360b1 --- /dev/null +++ b/changelog.d/6905-repsel-p2-specialized-abi.md @@ -0,0 +1,11 @@ +Representation-selection Phase 2 (`docs/representation-selection-rfc.md` §5.4–§5.6): the specialized calling convention — bounded monomorphization. Statically-proven call sites now call a full-body specialized entry with raw-typed args (typed-array header ptr, raw i32/f64), chosen statically with no per-call guard. + +- Full-body specialized entries: `compile_function` parameterized on a call-site-derived rep tuple (`Boxed`/`I32`/`F64`/`TaPtr{kind, const_len}`); internal `{public}__spec_` symbols emitted before the public bodies; mutually exclusive with `i64_specialized` and the typed_abi clone families; boxed return. `I32` params bind into Phase 1 canonical slots; `TaPtr` params bind as proven `BufferViewSlot`s with data pointer/length hoisted once at entry (sound: `GC_TYPE_TYPED_ARRAY`/`GC_TYPE_BUFFER` are non-movable, non-view typed arrays cannot detach or resize). +- `collectors/spec_abi_sites.rs` pre-pass (ctx-free, two-phase): proves `TaPtr` bindings (single top-level binding, non-view construction form, never reassigned module-wide, never closure-referenced) and judges direct call sites; dominant-tuple selection with callee-side demotion. Tier A dispatch re-proves every slot at the call site by construction (direct call, no diamond); Tier B keeps the guarded-diamond shape for declaration-proven tuples. Closures/wrappers/cross-module paths only ever reach the public boxed symbol (source-ratchet test). +- Proven-region tier: masked-window regions over fully compile-time-proven, undowngraded views lower as one guard-free fast copy (no probes, no alternate copies), and proven-view element stores join the region. New checked proven-view element access (`expr/proven_view_access.rs`): inline `icmp ult` bounds + bare load/store for dynamic exact-i32 indices, bit-exact with the runtime helpers. +- wrap-i32 additive accumulators: `int_valued_ta_locals` admits straight-line `Add`/`Sub` chains over exact operands (in-bounds-proven int-TA reads via constant lengths, literals, bitwise results, sibling candidates) carrying the ToInt32 image in the canonical i32 slot; loop-carried additive writes, unproven operands, and index-position reads stay rejected. +- FEAT_JSCVT: `toint32_wrap` emits `@llvm.aarch64.fjcvtzs` (spec-exact single-instruction ECMAScript ToInt32) on arm64 macOS targets (`PERRY_JSCVT` kill switch; iOS/tvOS device targets keep the portable tower — A7–A11 lack the instruction). +- Audits: `clamp_detect` gains the missing `was_plain_async` gates; top-level `const` numeric module bindings fold into `compile_time_constants` (TDZ-flagged bindings excluded); typed-array-typed `PutValueSet` element writes route to `index_set`'s typed arm; never-bound shadow-slot clears elided; spec `TaPtr` params skip the redundant callee-side root bind. +- Flags `PERRY_SPECIALIZED_ABI` (default on) and `PERRY_SPECIALIZED_ABI_MAX` (default 64) + `PERRY_JSCVT`, all object-cache-keyed; rejections recorded via the typed-clone rejection vocabulary; RFC §5.4/§8 updated — anti-bloat is proven by the empirical flag-on/off corpus measurement (the `binary-size` CI job only sees the compiler), measured at +0.0063% aggregate over a 32-binary corpus. + +RFC §7 acceptance met on the protocol box (min-of-9, cache-busted): real unrolled untyped bcryptjs `_encipher` (`enc_real.ts`) 834 → **126 ms vs Node 134 ms**, byte-exact; post-`-O3` specialized entry has zero `js_dyn_index_get`/`js_dynamic_*`/`js_typed_array_*` calls and zero kind guards (pure-i32 Feistel: `fadd 0`, `add i32 33`, `xor i32 50`). Four new routing gap tests (polymorphic coexistence, view+detach, reassignment, recursion/escape) pass byte-exact flag on/off and under `PERRY_GC_FORCE_EVACUATE=1`; full gap suite: no new untriaged failures. diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index 50d0d35d11..26614a2ea1 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -606,6 +606,18 @@ impl LlBlock { /// (poison otherwise); every clamped case is mathematically 0 anyway. pub fn toint32_wrap(&mut self, val: &str) -> String { use crate::types::{I1, I32, I64}; + // ARMv8.3 FEAT_JSCVT: `fjcvtzs` IS ECMAScript ToInt32 in one + // instruction — truncate toward zero, wrap modulo 2^32, NaN/±Inf/-0 + // → 0. Replaces the ~25-op branchless tower below on targets that + // have it (all Apple Silicon); the tower remains the portable path. + if crate::codegen::helpers::jscvt_enabled() { + let r = self.reg(); + self.emit(format!( + "{} = call i32 @llvm.aarch64.fjcvtzs(double {})", + r, val + )); + return r; + } let bits = self.bitcast_double_to_i64(val); let exp_shifted = self.lshr(I64, &bits, "52"); let bexp = self.and(I64, &exp_shifted, "2047"); diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 22b2388f22..7d080fd738 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -850,6 +850,9 @@ pub(super) fn compile_closure( integer_locals: native_facts.integer_locals(), not_bigint_locals: native_facts.not_bigint_locals(), unsigned_i32_locals: native_facts.unsigned_i32_locals(), + // Conservative: treat every slot as possibly-bound (param binds are + // emitted before FnCtx exists here), so clears never get skipped. + shadow_slots_bound: shadow_slot_map.values().copied().collect(), shadow_slot_map, persistent_shadow_slots: std::collections::HashSet::new(), shadow_slot_clears_after_stmt, @@ -866,6 +869,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..68a41ed565 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -749,6 +749,7 @@ pub(super) fn compile_module_entry( integer_locals: main_native_facts.integer_locals(), not_bigint_locals: main_native_facts.not_bigint_locals(), unsigned_i32_locals: main_native_facts.unsigned_i32_locals(), + shadow_slots_bound: main_shadow_slot_map.values().copied().collect(), shadow_slot_map: main_shadow_slot_map, persistent_shadow_slots: std::collections::HashSet::new(), shadow_slot_clears_after_stmt: main_shadow_slot_clears_after_stmt, @@ -768,6 +769,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(), @@ -1360,6 +1364,7 @@ pub(super) fn compile_module_entry( integer_locals: init_native_facts.integer_locals(), not_bigint_locals: init_native_facts.not_bigint_locals(), unsigned_i32_locals: init_native_facts.unsigned_i32_locals(), + shadow_slots_bound: init_shadow_slot_map.values().copied().collect(), shadow_slot_map: init_shadow_slot_map, persistent_shadow_slots: std::collections::HashSet::new(), shadow_slot_clears_after_stmt: init_shadow_slot_clears_after_stmt, @@ -1379,6 +1384,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..7f26971ebc 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,13 +444,61 @@ 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). No callee + // shadow binding is emitted — see the arm below: every route into this + // entry is a Tier-A call whose argument is the caller's proven, + // never-reassigned ROOTED binding, which keeps the header live for the + // whole call. + // - `Boxed`/`F64` → exactly today's binding (a raw f64 IS its box). + let mut spec_i32_param_slots: HashMap = HashMap::new(); + let mut bound_param_slots: HashSet = HashSet::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); + // No callee-side shadow binding: every route into this + // entry is a Tier-A call whose argument is a proven + // never-reassigned rooted binding (module-global root or + // caller-frame slot) that stays live for the whole call, + // and typed-array storage is non-movable — the callee + // root would be redundant TLS traffic on the hot path. + 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() { + bound_param_slots.insert(slot_idx); blk.call_void( "js_shadow_slot_bind", &[(I32, &slot_idx.to_string()), (PTR, &slot)], @@ -450,6 +520,27 @@ 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 @@ -466,7 +557,25 @@ pub(super) fn compile_function( .collect(); let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); - let native_facts = crate::collectors::collect_native_region_fact_graph( + // Spec-entry TaPtr params carry pre-pass-proven constant element counts — + // feed them to the fact collectors (in-bounds proofs for the wrap-i32 + // additive admission). + let spec_ta_lens: HashMap = spec_entry + .map(|plan| { + f.params + .iter() + .zip(plan.reps.iter()) + .filter_map(|(p, rep)| match rep { + crate::collectors::SpecParamRep::TaPtr { + const_len: Some(len), + .. + } => Some((p.id, *len)), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + let native_facts = crate::collectors::collect_native_region_fact_graph_with_spec_lens( &f.body, &f.params, &flat_const_ids, @@ -479,8 +588,23 @@ pub(super) fn compile_function( classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, + &spec_ta_lens, ); + if let Some(plan) = spec_entry { + if std::env::var("PERRY_REPSEL_DEBUG").as_deref() == Ok("1") { + eprintln!( + "repsel: spec entry '{}' tuple=[{}] [{}]", + f.name, + plan.reps + .iter() + .map(|r| r.label()) + .collect::>() + .join(","), + strings.module_prefix() + ); + } + } // Representation-selection Phase 1: canonical-i32 locals are allowed in // plain synchronous function bodies only. Async / generator / // `was_plain_async` bodies route locals through shared cells (the @@ -581,6 +705,7 @@ pub(super) fn compile_function( shadow_slot_map, persistent_shadow_slots: std::collections::HashSet::new(), shadow_slot_clears_after_stmt, + shadow_slots_bound: bound_param_slots, arena_state_slot: None, class_keys_slots: HashMap::new(), cached_lengths: HashMap::new(), @@ -590,10 +715,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 +868,76 @@ 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 stays + // live through the CALLER's proven never-reassigned rooted binding (a + // module-global root or the caller's own frame slot — the only routes into + // this entry are Tier-A calls whose args carry that proof); 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/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 0674e3f27d..c8bfdf5822 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -185,6 +185,30 @@ pub(crate) fn write_barriers_enabled() -> bool { thread_local! { static FULL_OUTLINE_IC: std::cell::Cell = const { std::cell::Cell::new(false) }; + static JSCVT: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// FEAT_JSCVT (`fjcvtzs`) availability for the CURRENT module's target: the +/// single-instruction, spec-exact ECMAScript `ToInt32` on ARMv8.3+. Only +/// arm64 macOS triples opt in — every Apple Silicon Mac (M1+) is ≥ ARMv8.4, +/// while iOS/tvOS device targets can still cover A7–A11 chips (ARMv8.0–8.2, +/// no JSCVT — `fjcvtzs` would be an illegal instruction) and generic aarch64 +/// (Graviton2/Neoverse-N1) lacks it too. `PERRY_JSCVT=0/off/false` reverts +/// `toint32_wrap` to the branchless shift/select tower (A/B bisection; keyed +/// into the object cache). Same thread-local per-module discipline as +/// `FULL_OUTLINE_IC` above. +pub(crate) fn jscvt_enabled() -> bool { + JSCVT.with(|c| c.get()) +} + +pub(crate) fn set_jscvt_for_target(triple: &str) { + let env_off = matches!( + std::env::var("PERRY_JSCVT").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ); + let target_has_jscvt = (triple.starts_with("arm64") || triple.starts_with("aarch64")) + && (triple.contains("apple-macosx") || triple.contains("apple-darwin")); + JSCVT.with(|c| c.set(target_has_jscvt && !env_off)); } /// Lever B (#5334) full-outline gate for class-field IC diamonds. Set ONCE per diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index a9fd211062..501a0ed485 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -473,6 +473,9 @@ pub(super) fn compile_method( integer_locals: native_facts.integer_locals(), not_bigint_locals: native_facts.not_bigint_locals(), unsigned_i32_locals: native_facts.unsigned_i32_locals(), + // Conservative: treat every slot as possibly-bound (param binds are + // emitted before FnCtx exists here), so clears never get skipped. + shadow_slots_bound: shadow_slot_map.values().copied().collect(), shadow_slot_map, persistent_shadow_slots: std::collections::HashSet::new(), shadow_slot_clears_after_stmt, @@ -489,6 +492,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(), @@ -1490,6 +1496,9 @@ pub(super) fn compile_static_method( integer_locals: native_facts.integer_locals(), not_bigint_locals: native_facts.not_bigint_locals(), unsigned_i32_locals: native_facts.unsigned_i32_locals(), + // Conservative: treat every slot as possibly-bound (param binds are + // emitted before FnCtx exists here), so clears never get skipped. + shadow_slots_bound: shadow_slot_map.values().copied().collect(), shadow_slot_map, persistent_shadow_slots: std::collections::HashSet::new(), shadow_slot_clears_after_stmt, @@ -1506,6 +1515,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..713cd747ee 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, @@ -157,6 +159,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // afresh for every module — including the `false` case, to clear any prior // module's decision on this thread. set_full_outline_ic(decide_full_outline_ic(module_callable_count(hir))); + // FEAT_JSCVT decision is per-target (apple-arm64 only) — same + // set-per-module discipline as the outline gate above. + helpers::set_jscvt_for_target(&triple); let mut llmod = LlModule::new_with_fp_flags(&triple, fp_flags); // Null guard global: a zeroed i32 used as a safe dereference target @@ -1090,6 +1095,45 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } } + // Representation-selection Phase 2: top-level `const = ` module bindings are compile-time constants by ECMAScript + // semantics (a `const` reassignment is a parse-time error), so their + // reads constant-fold — this is what proves `P[BLOWFISH_NUM_ROUNDS + 1]` + // in-bounds against a constant-length view. Excluded: any id carried by a + // `PreallocateBoxes`/`PreallocateTdzBoxes` statement — a box-backed slot + // holds a box pointer, and a TDZ-flagged binding must keep its + // ReferenceError on pre-declaration reads instead of folding to a value. + { + let mut prealloc_ids: std::collections::HashSet = std::collections::HashSet::new(); + for s in &hir.init { + if let perry_hir::Stmt::PreallocateBoxes(ids) + | perry_hir::Stmt::PreallocateTdzBoxes(ids) = s + { + prealloc_ids.extend(ids.iter().copied()); + } + } + for s in &hir.init { + if let perry_hir::Stmt::Let { + id, + mutable: false, + init: Some(init), + .. + } = s + { + if prealloc_ids.contains(id) { + continue; + } + let value = match init { + perry_hir::Expr::Integer(n) => Some(*n as f64), + perry_hir::Expr::Number(n) if n.is_finite() => Some(*n), + _ => None, + }; + if let Some(value) = value { + compile_time_constants.entry(*id).or_insert(value); + } + } + } + } // Issue #235: per-method explicit-param-count map covering BOTH local // classes (from `hir.classes`) AND imported classes (from @@ -1577,6 +1621,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 +2127,142 @@ 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 reassigned = crate::collectors::reassigned_locals(&f.body); + let demoted: Vec = f + .params + .iter() + .map(|p| reassigned.contains(&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 +2307,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 +2373,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..26767c5236 --- /dev/null +++ b/crates/perry-codegen/src/codegen/spec_abi.rs @@ -0,0 +1,318 @@ +//! 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_