diff --git a/changelog.d/7242-i64-spec-exactness.md b/changelog.d/7242-i64-spec-exactness.md new file mode 100644 index 0000000000..6c078cb78c --- /dev/null +++ b/changelog.d/7242-i64-spec-exactness.md @@ -0,0 +1 @@ +- **Removed the unproven i64 function specialization (#7238).** `emit_i64_specializations` re-emitted any `number`-typed function body in i64 arithmetic behind an f64 shim that `fptosi`'d every argument and `sitofp`'d the result. Two halves of its contract were unchecked, and they failed independently. **Overflow**: `add`/`sub`/`mul i64` are exact, while JS rounds to the nearest double at *every* operator, so the two agree only while each intermediate satisfies `|v| <= 2^53` — `grow(40, 1)` with `grow = (n, acc) => n === 0 ? acc : grow(n - 1, acc * 3 + 1)` wrapped past 2^63 and printed `-210245885124158400` where Node prints `18236498188585394000`. **Argument truncation**: a `number` parameter is a double, and `fptosi double %arg to i64` truncated a fractional one on entry — `frac(3, 0.5)` printed `0` instead of `4`, and `apply2(mulAdd, 1.5, 2.5)` printed `3` instead of `4.75`. Neither hole is repairable by narrowing the admission rule: #7237's `i32_chain_magnitude_bits` composes *bounded leaves* (an i32 slot, a literal's own width, a masked or shifted value, a `const`'s magnitude), and this pass has none — its leaves are `number` parameters, and it is only observable through self-recursion (where a parameter fed by its own recursive-call argument is unbounded by construction; even `fib(79)` crosses 2^53) or through an indirect call that HIR inlining does not flatten. A sound version needs a runtime guard plus a deopt edge to an f64 body, which the pass deliberately did not emit. Per CLAUDE.md's kill-policy it is removed rather than left as an unprovable mode. Removal also unblocks the specializers it was displacing — `typed_f64_functions`/`typed_i32_functions`/`typed_i1_functions` and the Phase-2 specialized ABI were all retained *minus* the i64-specialized set — so `benchmarks/suite/14_closure.ts`'s `compute` now takes a call-site-guarded `__typed_f64` clone instead of an assumed integer body. Verified by byte-for-byte LLVM IR comparison across all 30 `benchmarks/suite/` programs: 28 unchanged, and the two movers (`05_fibonacci`, `14_closure`) are exactly the two that carried a specialization. `compiler_output_regression.py census --gate` green on both compilers with byte-identical per-workload per-representation tables. New parity case `test-files/test_gap_7238_i64_specialization_exactness.ts` covers both shapes from the issue, an overflow landing between 2^53 and 2^63, fractional arguments, the 2^53 boundary from both sides, an indirect non-recursive call, and the chains that must stay exact — 11 lines diverge from Node 26.5.1 on unfixed `main`, byte-identical after. Cost, measured and stated: `05_fibonacci` is ~20% slower (`fib(40)` 450 ms → 555 ms, fixed arm slower in 9/9 interleaved pairs on a non-quiet host); `14_closure` is within noise. A sound guarded-plus-deopt recursive numeric specialization is tracked as a follow-up. diff --git a/crates/perry-codegen/src/codegen/i64_spec.rs b/crates/perry-codegen/src/codegen/i64_spec.rs deleted file mode 100644 index a0031470a1..0000000000 --- a/crates/perry-codegen/src/codegen/i64_spec.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Integer-specialization pass for `compile_module`. -//! -//! Extracted verbatim from the `compile_module` body (pure code move, no -//! behavior change). For pure numeric recursive functions (like fibonacci), -//! emits an i64 variant that uses integer registers and integer arithmetic; -//! the f64 wrapper calls fptosi → i64_fn → sitofp. Returns the set of FuncIds -//! that were specialized so the main compile loop can skip re-emitting them. - -use std::collections::HashMap; - -use perry_hir::Module as HirModule; - -use crate::module::LlModule; -use crate::types::{LlvmType, DOUBLE, I64}; - -// Collector and boxing-analysis walkers live in dedicated modules. - -/// Emit i64-specialized bodies (+ f64 wrappers) for integer-specializable -/// functions. Returns the set of specialized FuncIds. -pub(crate) fn emit_i64_specializations( - llmod: &mut LlModule, - hir: &HirModule, - func_names: &HashMap, - module_globals: &HashMap, -) -> std::collections::HashSet { - let mut i64_specialized: std::collections::HashSet = std::collections::HashSet::new(); - for f in &hir.functions { - // Skip integer specialization for functions that access module globals. - // The i64 body emitter can't handle module global loads (it produces - // `ret 0` instead of reading the global), creating a broken stub - // that shadows the real compiled function. - let uses_module_globals = f.body.iter().any(|s| { - fn walks(s: &perry_hir::Stmt, mg: &HashMap) -> bool { - match s { - perry_hir::Stmt::Return(Some(perry_hir::Expr::LocalGet(id))) => { - mg.contains_key(id) - } - perry_hir::Stmt::Expr(perry_hir::Expr::LocalGet(id)) => mg.contains_key(id), - _ => false, - } - } - walks(s, module_globals) - }); - // Skip clamp-shaped functions: their FuncRef call sites with provably - // i32 arguments are intrinsified to smax/smin and never call this - // symbol, so the only remaining callers are exactly the ones whose - // arguments are NOT integers (fractional doubles, NaN-boxed pointers) - // — and clamp3 returns an argument verbatim, so the wrapper's - // unconditional `fptosi` miscompiles every one of them (#4785 bug - // class: `(number).method is not a function`). Those callers need - // the real f64 body. - let is_clamp_shape = - crate::collectors::detect_clamp3(f).is_some() || crate::collectors::detect_clamp_u8(f); - if crate::collectors::is_integer_specializable(f) && !uses_module_globals && !is_clamp_shape - { - if let Some(llvm_name) = func_names.get(&f.id) { - let i64_name = format!("{}_i64", llvm_name); - crate::collectors::emit_i64_function(llmod, f, &i64_name); - // Emit the f64 wrapper that calls the i64 version. - // Mark as alwaysinline so LLVM exposes the integer ops - // to callers — critical for vectorizing clamp patterns. - let params: Vec<(LlvmType, String)> = f - .params - .iter() - .map(|p| (DOUBLE, format!("%arg{}", p.id))) - .collect(); - let wrapper = llmod.define_function(llvm_name, DOUBLE, params); - wrapper.force_inline = true; - let _ = wrapper.create_block("entry"); - let blk = wrapper.block_mut(0).unwrap(); - let mut i64_args: Vec<(LlvmType, String)> = Vec::new(); - for p in &f.params { - let i64_v = blk.fptosi(DOUBLE, &format!("%arg{}", p.id), I64); - i64_args.push((I64, i64_v)); - } - let refs: Vec<(LlvmType, &str)> = - i64_args.iter().map(|(t, v)| (*t, v.as_str())).collect(); - let i64_result = blk.call(I64, &i64_name, &refs); - let f64_result = blk.sitofp(I64, &i64_result, DOUBLE); - blk.ret(DOUBLE, &f64_result); - i64_specialized.insert(f.id); - } - } - } - i64_specialized -} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 9df1d76da2..c1b739c641 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -50,10 +50,11 @@ mod function; // `pub(crate)` so `crate::linker` can read the inline-hot-small policy // (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`). pub(crate) mod helpers; -mod i64_spec; mod method; mod method_registry; mod module_globals_emit; +#[cfg(test)] +mod number_exactness_tests; mod opts; mod spec_abi; mod string_pool; @@ -2152,82 +2153,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } - // Integer-specialization pass. See `i64_spec::emit_i64_specializations`. - let i64_specialized = - i64_spec::emit_i64_specializations(&mut llmod, hir, &func_names, &module_globals); - - // From here on, this set means "a typed-f64 clone is present in the - // module", not just "the HIR body was eligible." The i64 specializer owns - // its public wrapper and may skip the ordinary f64 body entirely, so direct - // call lowering must not branch to an unemitted typed-f64 clone. - for f in &hir.functions { - if i64_specialized.contains(&f.id) && cross_module.typed_f64_functions.contains(&f.id) { - record_typed_clone_rejection( - &mut typed_clone_rejection_records, - f.name.clone(), - "typed_f64_function_clone_decision", - typed_abi::TypedCloneRejectionReason::I64Specialized, - vec![ - "typed_clone_kind=typed_f64_function".to_string(), - format!("function_id={}", f.id), - format!( - "symbol={}", - func_names.get(&f.id).map(String::as_str).unwrap_or(&f.name) - ), - ], - ); - } - if i64_specialized.contains(&f.id) && cross_module.typed_i32_functions.contains(&f.id) { - record_typed_clone_rejection( - &mut typed_clone_rejection_records, - f.name.clone(), - "typed_i32_function_clone_decision", - typed_abi::TypedCloneRejectionReason::I64Specialized, - vec![ - "typed_clone_kind=typed_i32_function".to_string(), - format!("function_id={}", f.id), - format!( - "symbol={}", - func_names.get(&f.id).map(String::as_str).unwrap_or(&f.name) - ), - ], - ); - } - if i64_specialized.contains(&f.id) && cross_module.typed_i1_functions.contains(&f.id) { - record_typed_clone_rejection( - &mut typed_clone_rejection_records, - f.name.clone(), - "typed_i1_function_clone_decision", - typed_abi::TypedCloneRejectionReason::I64Specialized, - vec![ - "typed_clone_kind=typed_i1_function".to_string(), - format!("function_id={}", f.id), - format!( - "symbol={}", - func_names.get(&f.id).map(String::as_str).unwrap_or(&f.name) - ), - ], - ); - } - } - cross_module - .typed_f64_functions - .retain(|id| !i64_specialized.contains(id)); - cross_module - .typed_i32_functions - .retain(|id| !i64_specialized.contains(id)); - cross_module - .typed_i1_functions - .retain(|id| !i64_specialized.contains(id)); - cross_module - .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. + // Runs AFTER 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(); @@ -2295,13 +2225,6 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> ); 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) @@ -2434,11 +2357,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> .with_context(|| format!("lowering specialized entry for function '{}'", f.name))?; } - // Lower each user function into the module (skip i64-specialized ones). + // Lower each user function into the module. for f in &hir.functions { - if i64_specialized.contains(&f.id) { - continue; - } let typed_public_trampoline = if cross_module.typed_f64_functions.contains(&f.id) { Some(typed_abi::TypedFunctionTrampolineKind::F64) } else if cross_module.typed_i32_functions.contains(&f.id) { diff --git a/crates/perry-codegen/src/codegen/number_exactness_tests.rs b/crates/perry-codegen/src/codegen/number_exactness_tests.rs new file mode 100644 index 0000000000..4df248fcfb --- /dev/null +++ b/crates/perry-codegen/src/codegen/number_exactness_tests.rs @@ -0,0 +1,347 @@ +//! #7238 — a `number`-typed function body must not be re-emitted in integer +//! registers behind an `fptosi`/`sitofp` shim. +//! +//! `emit_i64_specializations` used to do exactly that for any function whose +//! return type and every parameter type was `number` and whose body was +//! Add/Sub/Mul/Compare/Conditional over locals, params, integer literals and +//! self-calls. Two halves of the contract went unproven: +//! +//! * **Argument domain.** A `number` parameter is an IEEE-754 double. The +//! wrapper's `fptosi double %arg to i64` truncated a fractional argument on +//! entry, and `sitofp` on the way out could not represent a fractional +//! result at all. +//! * **Magnitude.** i64 `add`/`sub`/`mul` are exact; JS rounds to the nearest +//! double at *every* operator. They agree only while each intermediate +//! satisfies `|v| <= 2^53`. +//! +//! Neither is statically provable for the self-recursive bodies the pass +//! existed to serve — a parameter fed by its own recursive call argument has no +//! bound, so #7237's `i32_chain_magnitude_bits` has no bounded leaf to measure +//! from. The pass was removed; these tests are the guard against reintroducing +//! it, and against losing the sound specializations that now claim the same +//! functions. + +use crate::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +fn ir_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + nextjs_path_init_modules: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: crate::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn number_param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn number_fn(id: u32, name: &str, params: Vec, body: Vec) -> Function { + Function { + id, + name: name.to_string(), + type_params: Vec::new(), + params, + return_type: Type::Number, + body, + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + } +} + +fn module_with(functions: Vec) -> Module { + Module { + name: "number_exactness.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions, + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn emitted_ir(functions: Vec) -> String { + String::from_utf8(compile_module(&module_with(functions), ir_opts()).unwrap()) + .expect("LLVM IR should be UTF-8") +} + +/// Slice out the `define`d function whose signature line contains `marker`. +fn function_ir<'a>(ir: &'a str, marker: &str) -> Option<&'a str> { + let start = ir + .match_indices("define ") + .find(|(i, _)| { + let line_end = ir[*i..].find('\n').map(|n| i + n).unwrap_or(ir.len()); + ir[*i..line_end].contains(marker) + }) + .map(|(i, _)| i)?; + let end = ir[start..].find("\n}")? + start; + Some(&ir[start..end]) +} + +/// `function fib(n: number): number { if (n <= 1) return n; return fib(n-1) + fib(n-2); }` +fn fib_fn() -> Function { + let call = |k: i64| Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::Binary { + op: BinaryOp::Sub, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::Integer(k)), + }], + type_args: Vec::new(), + byte_offset: 0, + }; + number_fn( + 1, + "fib", + vec![number_param(10, "n")], + vec![ + Stmt::If { + condition: Expr::Compare { + op: CompareOp::Le, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::Integer(1)), + }, + then_branch: vec![Stmt::Return(Some(Expr::LocalGet(10)))], + else_branch: None, + }, + Stmt::Return(Some(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(call(1)), + right: Box::new(call(2)), + })), + ], + ) +} + +/// `function grow(n: number, acc: number): number { +/// return n === 0 ? acc : grow(n - 1, acc * 3 + 1); }` +/// — the issue's overflow repro: `grow(40, 1)` wrapped past 2^63 and went +/// negative under the exact i64 chain. +fn grow_fn() -> Function { + number_fn( + 1, + "grow", + vec![number_param(10, "n"), number_param(11, "acc")], + vec![Stmt::Return(Some(Expr::Conditional { + condition: Box::new(Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::Integer(0)), + }), + then_expr: Box::new(Expr::LocalGet(11)), + else_expr: Box::new(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![ + Expr::Binary { + op: BinaryOp::Sub, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::Integer(1)), + }, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::LocalGet(11)), + right: Box::new(Expr::Integer(3)), + }), + right: Box::new(Expr::Integer(1)), + }, + ], + type_args: Vec::new(), + byte_offset: 0, + }), + }))], + ) +} + +/// `function add(a: number, b: number): number { return a + b; }` — the +/// straight-line shape the pass also claimed, which is now free to take the +/// sound typed-f64 clone instead. +fn add_fn() -> Function { + number_fn( + 1, + "add", + vec![number_param(10, "a"), number_param(11, "b")], + vec![Stmt::Return(Some(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::LocalGet(11)), + }))], + ) +} + +const NO_I64_BODY: &str = "no `number` function may be re-emitted as an i64 body"; + +#[test] +fn self_recursive_number_function_gets_no_i64_body() { + for f in [fib_fn(), grow_fn()] { + let name = f.name.clone(); + let ir = emitted_ir(vec![f]); + assert!( + !ir.contains(&format!("{name}_i64")), + "{NO_I64_BODY}, but `{name}` still has one:\n{ir}" + ); + // Independent of the `_i64` naming convention: no user function in + // this module may be defined with an integer return type at all. + assert!( + !ir.contains("define i64 @perry_fn_number_exactness"), + "{NO_I64_BODY}, but an i64 user-function body was emitted:\n{ir}" + ); + } +} + +#[test] +fn self_recursive_number_function_keeps_a_double_body() { + for f in [fib_fn(), grow_fn()] { + let name = f.name.clone(); + let ir = emitted_ir(vec![f]); + let symbol = format!("@perry_fn_number_exactness_ts__{name}("); + let body = function_ir(&ir, &symbol) + .unwrap_or_else(|| panic!("public f64 body for `{name}` must be emitted:\n{ir}")); + // The removed wrapper was exactly `fptosi` → `call i64` → `sitofp`, + // with no other instruction. Its argument truncation is the second of + // the two defects and is what this asserts is gone. Matched on the + // opcode alone rather than on `fptosi double %arg`, so a rename of the + // emitted parameters cannot quietly turn this into a vacuous check — + // neither fixture has any other reason to narrow a double to an + // integer. + assert!( + !body.contains("fptosi"), + "`{name}`'s public body must not truncate its arguments on entry:\n{body}" + ); + assert!( + body.contains("call double @perry_fn_number_exactness_ts__"), + "`{name}` must still recurse through a double-typed body:\n{body}" + ); + } +} + +/// The pass suppressed the ordinary f64 body *and* the typed-ABI clone +/// families (`typed_f64_functions` and friends were retained minus the +/// i64-specialized set). Removing it hands these functions back to the sound +/// specializer, so coverage moves rather than disappears. +#[test] +fn straight_line_number_function_takes_the_typed_f64_clone() { + let ir = emitted_ir(vec![add_fn()]); + assert!(!ir.contains("add_i64"), "{NO_I64_BODY}:\n{ir}"); + assert!( + ir.contains("__typed_f64"), + "the typed-f64 clone must now be reachable for a plain `a + b`:\n{ir}" + ); +} + +/// A fractional `Number` literal in the body was already rejected by the old +/// gate (#6221). It must stay rejected — and now for the whole class of +/// reasons, not just that one literal. +#[test] +fn fractional_literal_body_stays_on_the_double_path() { + let f = number_fn( + 1, + "halfDown", + vec![number_param(10, "n")], + vec![Stmt::Return(Some(Expr::Conditional { + condition: Box::new(Expr::Compare { + op: CompareOp::Le, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::Integer(0)), + }), + then_expr: Box::new(Expr::Number(0.5)), + else_expr: Box::new(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::Binary { + op: BinaryOp::Sub, + left: Box::new(Expr::LocalGet(10)), + right: Box::new(Expr::Integer(1)), + }], + type_args: Vec::new(), + byte_offset: 0, + }), + }))], + ); + let ir = emitted_ir(vec![f]); + assert!(!ir.contains("halfDown_i64"), "{NO_I64_BODY}:\n{ir}"); +} diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 39435a42cc..293d351ef4 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -740,7 +740,7 @@ pub(crate) struct CrossModuleCtx { /// 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`. + /// exclusive with the typed_abi clone families. 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`). diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 85e6af4b4a..8d09fc2c71 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -223,7 +223,6 @@ pub(crate) enum TypedCloneRejectionReason { ReturnExprNotTypedI32Safe, ReturnExprNotTypedI1Safe, ReturnExprNotTypedStringSafe, - I64Specialized, NoReceiverField, ReceiverClassExtends, ReceiverClassHasAccessor, @@ -273,7 +272,6 @@ impl TypedCloneRejectionReason { Self::ReturnExprNotTypedI32Safe => "return_expr_not_typed_i32_safe", Self::ReturnExprNotTypedI1Safe => "return_expr_not_typed_i1_safe", Self::ReturnExprNotTypedStringSafe => "return_expr_not_typed_string_safe", - Self::I64Specialized => "i64_specialized", Self::NoReceiverField => "no_receiver_field", Self::ReceiverClassExtends => "receiver_class_extends", Self::ReceiverClassHasAccessor => "receiver_class_has_accessor", diff --git a/crates/perry-codegen/src/codegen/typed_abi_opt_report.rs b/crates/perry-codegen/src/codegen/typed_abi_opt_report.rs index f60bf80a1c..a77294a75c 100644 --- a/crates/perry-codegen/src/codegen/typed_abi_opt_report.rs +++ b/crates/perry-codegen/src/codegen/typed_abi_opt_report.rs @@ -57,12 +57,6 @@ impl TypedCloneRejectionReason { Tier::CompilerLimitation, None, ), - Self::I64Specialized => ( - "the function is already i64-specialized, which takes \ - precedence over a representation-tuple entry.", - Tier::CompilerLimitation, - None, - ), Self::Captures | Self::CapturesThis | Self::CapturesNewTarget => ( "the function captures from an enclosing scope; captures are \ passed through the closure environment, not the raw ABI.", diff --git a/crates/perry-codegen/src/collectors/clamp_detect.rs b/crates/perry-codegen/src/collectors/clamp_detect.rs index 560bbe1be7..7684f771c9 100644 --- a/crates/perry-codegen/src/collectors/clamp_detect.rs +++ b/crates/perry-codegen/src/collectors/clamp_detect.rs @@ -150,23 +150,14 @@ pub fn detect_clamp_u8(f: &Function) -> bool { matches!(&f.body[2], Stmt::Return(Some(e)) if returns_int_expr(e)) } -/// A function is i64-specializable if it's a pure numeric recursive fn. -pub fn is_integer_specializable(f: &Function) -> bool { - if f.is_async || f.is_generator || f.was_plain_async { - return false; - } - if !matches!(f.return_type, perry_hir::types::Type::Number) { - return false; - } - if !f - .params - .iter() - .all(|p| matches!(p.ty, perry_hir::types::Type::Number)) - { - return false; - } - i64s_stmts(&f.body, f.id) -} +// `is_integer_specializable` / `i64s_stmts` / `i64s_expr` used to live here. +// They were the admission rule for the whole-function i64 specialization pass +// (`codegen/i64_spec.rs`), removed in #7238 — see that issue and the module +// header of `test-files/test_gap_7238_i64_specialization_exactness.ts` for why +// the rule could not be repaired: it admitted `number` parameters as integers +// without proof and bounded no intermediate, and neither is statically +// provable for the self-recursive bodies the pass existed to serve. + /// Detect functions that always return an integer value (all return paths /// end with `| 0`, `>>> 0`, or another bitwise op). These functions can be /// treated as int-producing at call sites, enabling the i32 fast path for @@ -256,47 +247,3 @@ pub fn returns_int_expr(e: &Expr) -> bool { _ => false, } } - -pub fn i64s_stmts(ss: &[Stmt], sid: u32) -> bool { - ss.iter().all(|s| match s { - Stmt::Return(Some(e)) => i64s_expr(e, sid), - Stmt::Return(None) => true, - Stmt::If { - condition, - then_branch, - else_branch, - } => { - i64s_expr(condition, sid) - && i64s_stmts(then_branch, sid) - && else_branch.as_ref().is_none_or(|eb| i64s_stmts(eb, sid)) - } - Stmt::Expr(e) | Stmt::Let { init: Some(e), .. } => i64s_expr(e, sid), - Stmt::Let { init: None, .. } => true, - _ => false, - }) -} -pub fn i64s_expr(e: &Expr, sid: u32) -> bool { - match e { - Expr::Integer(_) | Expr::LocalGet(_) => true, - // The i64 emitter lowers Number literals with `as i64`, so only admit - // values that round-trip exactly — a fractional constant (`? 0.5 :`) - // would silently truncate in the specialized body. - Expr::Number(n) => *n as i64 as f64 == *n, - Expr::Binary { op, left, right } => { - matches!(op, BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul) - && i64s_expr(left, sid) - && i64s_expr(right, sid) - } - Expr::Compare { left, right, .. } => i64s_expr(left, sid) && i64s_expr(right, sid), - Expr::Call { callee, args, .. } => { - matches!(callee.as_ref(), Expr::FuncRef(id) if *id == sid) - && args.iter().all(|a| i64s_expr(a, sid)) - } - Expr::Conditional { - condition, - then_expr, - else_expr, - } => i64s_expr(condition, sid) && i64s_expr(then_expr, sid) && i64s_expr(else_expr, sid), - _ => false, - } -} diff --git a/crates/perry-codegen/src/collectors/i64_emit.rs b/crates/perry-codegen/src/collectors/i64_emit.rs deleted file mode 100644 index f927f4206c..0000000000 --- a/crates/perry-codegen/src/collectors/i64_emit.rs +++ /dev/null @@ -1,212 +0,0 @@ -use perry_hir::{BinaryOp, Expr, Function, Stmt}; - -/// Emit an i64-specialized function directly as LLVM IR text. -pub fn emit_i64_function(llmod: &mut crate::module::LlModule, f: &Function, i64_name: &str) { - use crate::types::I64; - let params: Vec<(crate::types::LlvmType, String)> = f - .params - .iter() - .map(|p| (I64, format!("%arg{}", p.id))) - .collect(); - let lf = llmod.define_function(i64_name, I64, params); - lf.force_inline = true; - let _ = lf.create_block("entry"); - let mut locals: std::collections::HashMap = std::collections::HashMap::new(); - { - let blk = lf.block_mut(0).unwrap(); - for p in &f.params { - let slot = blk.alloca(I64); - blk.store(I64, &format!("%arg{}", p.id), &slot); - locals.insert(p.id, slot); - } - } - let mut cx = I64Cx { - f: lf, - cur: 0, - locals, - sn: i64_name.to_string(), - sid: f.id, - }; - i64_body(&mut cx, &f.body); - if !cx.f.block_mut(cx.cur).unwrap().is_terminated() { - cx.f.block_mut(cx.cur).unwrap().ret(I64, "0"); - } -} -pub(crate) struct I64Cx<'a> { - f: &'a mut crate::function::LlFunction, - cur: usize, - locals: std::collections::HashMap, - sn: String, - sid: u32, -} - -pub fn i64_body(cx: &mut I64Cx<'_>, ss: &[Stmt]) { - use crate::types::I64; - for s in ss { - if cx.f.block_mut(cx.cur).unwrap().is_terminated() { - break; - } - match s { - Stmt::Return(Some(e)) => { - let v = i64_val(cx, e); - cx.f.block_mut(cx.cur).unwrap().ret(I64, &v); - } - Stmt::Return(None) => { - cx.f.block_mut(cx.cur).unwrap().ret(I64, "0"); - } - Stmt::Let { - id, init: Some(e), .. - } => { - let v = i64_val(cx, e); - let slot = cx.f.block_mut(cx.cur).unwrap().alloca(I64); - cx.f.block_mut(cx.cur).unwrap().store(I64, &v, &slot); - cx.locals.insert(*id, slot); - } - Stmt::Let { id, init: None, .. } => { - let slot = cx.f.block_mut(cx.cur).unwrap().alloca(I64); - cx.f.block_mut(cx.cur).unwrap().store(I64, "0", &slot); - cx.locals.insert(*id, slot); - } - Stmt::Expr(e) => { - let _ = i64_val(cx, e); - } - Stmt::If { - condition, - then_branch, - else_branch, - } => { - let cond = i64_cond(cx, condition); - let _ = cx.f.create_block("i64.then"); - let ti = cx.f.num_blocks() - 1; - let tl = cx.f.blocks()[ti].label.clone(); - let ei = if else_branch.is_some() { - let _ = cx.f.create_block("i64.else"); - cx.f.num_blocks() - 1 - } else { - 0 - }; - let el = if else_branch.is_some() { - cx.f.blocks()[ei].label.clone() - } else { - String::new() - }; - let _ = cx.f.create_block("i64.merge"); - let mi = cx.f.num_blocks() - 1; - let ml = cx.f.blocks()[mi].label.clone(); - let target_else = if else_branch.is_some() { &el } else { &ml }; - cx.f.block_mut(cx.cur) - .unwrap() - .cond_br(&cond, &tl, target_else); - cx.cur = ti; - i64_body(cx, then_branch); - if !cx.f.block_mut(cx.cur).unwrap().is_terminated() { - cx.f.block_mut(cx.cur).unwrap().br(&ml); - } - if let Some(eb) = else_branch { - cx.cur = ei; - i64_body(cx, eb); - if !cx.f.block_mut(cx.cur).unwrap().is_terminated() { - cx.f.block_mut(cx.cur).unwrap().br(&ml); - } - } - cx.cur = mi; - } - _ => {} - } - } -} -pub fn i64_cond(cx: &mut I64Cx<'_>, e: &Expr) -> String { - use crate::types::I64; - if let Expr::Compare { op, left, right } = e { - let l = i64_val(cx, left); - let r = i64_val(cx, right); - let blk = cx.f.block_mut(cx.cur).unwrap(); - return match op { - perry_hir::CompareOp::Le => blk.icmp_sle(I64, &l, &r), - perry_hir::CompareOp::Lt => blk.icmp_slt(I64, &l, &r), - perry_hir::CompareOp::Gt => blk.icmp_sgt(I64, &l, &r), - perry_hir::CompareOp::Ge => blk.icmp_sge(I64, &l, &r), - perry_hir::CompareOp::Eq | perry_hir::CompareOp::LooseEq => blk.icmp_eq(I64, &l, &r), - perry_hir::CompareOp::Ne | perry_hir::CompareOp::LooseNe => blk.icmp_ne(I64, &l, &r), - }; - } - let v = i64_val(cx, e); - cx.f.block_mut(cx.cur).unwrap().icmp_ne(I64, &v, "0") -} -pub fn i64_val(cx: &mut I64Cx<'_>, e: &Expr) -> String { - use crate::types::I64; - match e { - Expr::Integer(n) => n.to_string(), - Expr::Number(n) => (*n as i64).to_string(), - Expr::LocalGet(id) => { - if let Some(slot) = cx.locals.get(id).cloned() { - cx.f.block_mut(cx.cur).unwrap().load(I64, &slot) - } else { - "0".to_string() - } - } - Expr::Binary { op, left, right } => { - let l = i64_val(cx, left); - let r = i64_val(cx, right); - let blk = cx.f.block_mut(cx.cur).unwrap(); - match op { - BinaryOp::Add => blk.add(I64, &l, &r), - BinaryOp::Sub => blk.sub(I64, &l, &r), - BinaryOp::Mul => blk.mul(I64, &l, &r), - _ => "0".to_string(), - } - } - Expr::Call { callee, args, .. } => { - if let Expr::FuncRef(id) = callee.as_ref() { - if *id == cx.sid { - let mut lo: Vec<(crate::types::LlvmType, String)> = Vec::new(); - for a in args { - let v = i64_val(cx, a); - lo.push((I64, v)); - } - let refs: Vec<(crate::types::LlvmType, &str)> = - lo.iter().map(|(t, v)| (*t, v.as_str())).collect(); - let nm = cx.sn.clone(); - return cx.f.block_mut(cx.cur).unwrap().call(I64, &nm, &refs); - } - } - "0".to_string() - } - Expr::Conditional { - condition, - then_expr, - else_expr, - } => { - // Must be real control flow, not `select`: a branch can hold the - // self-recursive call, and evaluating both sides unconditionally - // would recurse past the base case. - let slot = cx.f.block_mut(cx.cur).unwrap().alloca(I64); - let cond = i64_cond(cx, condition); - let _ = cx.f.create_block("i64.cond.then"); - let ti = cx.f.num_blocks() - 1; - let tl = cx.f.blocks()[ti].label.clone(); - let _ = cx.f.create_block("i64.cond.else"); - let ei = cx.f.num_blocks() - 1; - let el = cx.f.blocks()[ei].label.clone(); - let _ = cx.f.create_block("i64.cond.merge"); - let mi = cx.f.num_blocks() - 1; - let ml = cx.f.blocks()[mi].label.clone(); - cx.f.block_mut(cx.cur).unwrap().cond_br(&cond, &tl, &el); - cx.cur = ti; - let tv = i64_val(cx, then_expr); - let blk = cx.f.block_mut(cx.cur).unwrap(); - blk.store(I64, &tv, &slot); - blk.br(&ml); - cx.cur = ei; - let ev = i64_val(cx, else_expr); - let blk = cx.f.block_mut(cx.cur).unwrap(); - blk.store(I64, &ev, &slot); - blk.br(&ml); - cx.cur = mi; - cx.f.block_mut(cx.cur).unwrap().load(I64, &slot) - } - _ => "0".to_string(), - } -} - -// ── Escape analysis for scalar replacement of non-escaping objects ── diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index ce3cb8441b..5aeb8d6ea7 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -16,7 +16,6 @@ mod escape_objects; mod hir_facts; mod hot_callees; mod i32_locals; -mod i64_emit; mod index_uses; mod int_valued_ta_locals; mod integer_locals; @@ -42,13 +41,9 @@ mod spec_abi_sites; mod this_as_value; mod uppercase_strings; -// Public re-exports for the visible API (`pub fn emit_i64_function` etc.). +// Public re-exports for the visible API. pub use cjs_scaffolding::{census as cjs_preamble_census, CjsPreambleCensus}; -pub use clamp_detect::{ - detect_clamp3, detect_clamp_u8, is_integer_specializable, returns_i32_identity_arg, - returns_integer, -}; -pub use i64_emit::emit_i64_function; +pub use clamp_detect::{detect_clamp3, detect_clamp_u8, returns_i32_identity_arg, returns_integer}; // Internal-to-crate re-exports — explicit names because globs don't // transitively expose through `pub(crate) use crate::collectors::*`. diff --git a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs index 93357d5a33..61a935e8a2 100644 --- a/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs +++ b/crates/perry-codegen/tests/i64_spec_ternary_recursion.rs @@ -1,10 +1,19 @@ -//! #6221: a self-recursive function whose recursive call sits inside a -//! ternary was admitted by the i64-specialization gate (`i64s_expr` accepts -//! `Expr::Conditional`) but the i64 body emitter had no `Conditional` arm and -//! fell into its `_ => "0"` catch-all — producing an empty specialized body -//! (`ret i64 0`) that shadowed the real function. Also covers the sibling -//! gate bug: fractional `Number` literals were admitted and then truncated -//! by the emitter's `as i64` lowering. +//! #6221 / #7238 — the ternary-recursion shape that the i64-specialization +//! pass mis-lowered, kept as a permanent guard now that the pass is gone. +//! +//! #6221: a self-recursive function whose recursive call sat inside a ternary +//! was admitted by the gate (`i64s_expr` accepted `Expr::Conditional`) but the +//! i64 body emitter had no `Conditional` arm and fell into its `_ => "0"` +//! catch-all — an empty specialized body (`ret i64 0`) that shadowed the real +//! function. A `Conditional` arm was added, and fractional `Number` literals +//! were excluded from the gate because the emitter's `as i64` truncated them. +//! +//! #7238 removed the pass outright: the fractional-literal exclusion only +//! covered literals *inside* the body, while every `number` **parameter** was +//! `fptosi`'d on entry by the wrapper, and no intermediate was bounded at +//! `2^53` where JS starts rounding and exact i64 arithmetic does not. Neither +//! is statically provable for a self-recursive `number` signature. Both +//! shapes below must therefore keep an exact double body. use perry_codegen::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::Type; @@ -160,40 +169,52 @@ fn function_body<'a>(ir: &'a str, marker: &str) -> Option<&'a str> { } #[test] -fn ternary_self_recursion_gets_real_i64_body() { +fn ternary_self_recursion_keeps_an_exact_double_body() { let f = ternary_recursive_fn(1, "idDown", Expr::Number(100.0)); let ir = String::from_utf8(compile_module(&module_with(vec![f]), empty_opts()).unwrap()).unwrap(); - let body = - function_body(&ir, "idDown_i64").expect("i64 specialization for idDown should be emitted"); - // The specialized body must branch on the ternary condition and make the - // self-recursive call — not collapse to the empty `ret i64 0` stub. assert!( - body.contains("br i1"), - "ternary must lower to a conditional branch, got:\n{body}" + !ir.contains("idDown_i64"), + "a `number` body must not be re-emitted in i64 registers:\n{ir}" + ); + let body = function_body(&ir, "@perry_fn_i64_spec_ternary_ts__idDown(") + .expect("the public f64 body for idDown must be emitted"); + // The removed wrapper was exactly `fptosi` → `call i64` → `sitofp`. + // Matched on the opcode alone, not on `fptosi double %arg`, so renaming + // the emitted parameters cannot make this vacuous — this fixture has no + // other reason to narrow a double to an integer. + assert!( + !body.contains("fptosi"), + "the public body must not truncate its argument on entry:\n{body}" ); + // The ternary still has to be real control flow — the #6221 shape. assert!( - body.contains("call i64"), - "recursive call must survive in the i64 body, got:\n{body}" + body.contains("br i1"), + "ternary must lower to a conditional branch, got:\n{body}" ); assert!( - !body.trim_end().ends_with("ret i64 0") || body.contains("br i1"), - "i64 body is the empty stub:\n{body}" + body.contains("call double"), + "recursive call must survive in the double body, got:\n{body}" ); } #[test] -fn fractional_literal_blocks_i64_specialization() { - // `return n <= 0 ? 0.5 : halfDown(n - 1);` — the i64 emitter would - // truncate 0.5 to 0, so the gate must reject the function entirely and - // leave the exact f64 body in place. +fn fractional_literal_body_keeps_an_exact_double_body() { + // `return n <= 0 ? 0.5 : halfDown(n - 1);` — an i64 lowering would + // truncate 0.5 to 0. let f = ternary_recursive_fn(1, "halfDown", Expr::Number(0.5)); let ir = String::from_utf8(compile_module(&module_with(vec![f]), empty_opts()).unwrap()).unwrap(); assert!( !ir.contains("halfDown_i64"), - "function with a fractional literal must not be i64-specialized" + "a `number` body must not be re-emitted in i64 registers:\n{ir}" + ); + let body = function_body(&ir, "@perry_fn_i64_spec_ternary_ts__halfDown(") + .expect("the public f64 body for halfDown must be emitted"); + assert!( + !body.contains("fptosi"), + "a `number` function must not truncate its arguments on entry:\n{body}" ); } diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index a3f7efcc87..c6c4c36501 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -7484,7 +7484,9 @@ fn typed_f64_clone_test_module(use_any_param: bool) -> Module { } } -fn typed_f64_i64_specialized_collision_module() -> Module { +/// `function add(a: number, b: number): number { return a + b; }` — the +/// simplest shape the removed i64 specializer claimed (#7238). +fn number_add_module() -> Module { let mut module = typed_f64_clone_test_module(false); module.functions[0].body = vec![Stmt::Return(Some(Expr::Binary { op: BinaryOp::Add, @@ -10030,19 +10032,33 @@ fn typed_f64_public_trampoline_dispatches_before_generic_body() { ); } +/// #7238: the whole-function i64 specializer used to claim this shape (`a + b` +/// over two `number` params) and, in claiming it, suppress both the ordinary +/// f64 body and every typed-ABI clone. It was removed because neither half of +/// its contract — integral arguments, `|intermediate| <= 2^53` — is statically +/// provable for a `number` signature. The coverage it displaced is what must +/// now be present. #[test] -fn typed_f64_function_clone_does_not_call_unemitted_i64_specialized_clone() { - let ir = String::from_utf8( - compile_module(&typed_f64_i64_specialized_collision_module(), empty_opts()).unwrap(), - ) - .unwrap(); +fn number_add_function_takes_the_typed_f64_clone_not_an_i64_body() { + let ir = + String::from_utf8(compile_module(&number_add_module(), empty_opts()).unwrap()).unwrap(); + assert!( + !ir.contains("perry_fn_typed_f64_function_abi_ts__add_i64"), + "a `number` body must not be re-emitted in i64 registers:\n{ir}" + ); + // Scoped to the public body and matched on the opcode alone, so neither a + // parameter rename nor an unrelated `fptosi` elsewhere in the module can + // make this vacuous. `a + b` has no reason to narrow a double to an + // integer. + let public = function_ir_section(&ir, "perry_fn_typed_f64_function_abi_ts__add"); assert!( - ir.contains("define i64 @perry_fn_typed_f64_function_abi_ts__add_i64"), - "fixture should exercise the existing i64 specializer:\n{ir}" + !public.contains("fptosi"), + "a `number` function must not truncate its arguments on entry:\n{public}" ); assert!( - !ir.contains("__typed_f64"), - "i64-specialized functions must not select a missing typed-f64 clone:\n{ir}" + ir.contains("__typed_f64"), + "the typed-f64 clone must be reachable once the i64 pass no longer \ + suppresses it:\n{ir}" ); } diff --git a/test-files/test_gap_7238_i64_specialization_exactness.ts b/test-files/test_gap_7238_i64_specialization_exactness.ts new file mode 100644 index 0000000000..09a08d0acc --- /dev/null +++ b/test-files/test_gap_7238_i64_specialization_exactness.ts @@ -0,0 +1,114 @@ +// #7238 — an i64 function specialization must not stand in for `number` +// arithmetic it cannot prove exact. +// +// `emit_i64_specializations` re-emitted a whole `number`-typed function body in +// i64 registers and wrapped it in an f64 shim that `fptosi`d every argument and +// `sitofp`d the result. Two independent halves of the contract went unchecked: +// +// 1. OVERFLOW. i64 `add`/`sub`/`mul` compute the exact two's-complement +// result. JS evaluates the same chain in doubles, rounding at every +// operator, so the two agree only while each intermediate satisfies +// |v| <= 2^53. Past that the answers merely differ; past 2^63 the i64 +// chain wraps and the sign flips. +// 2. ARGUMENT TRUNCATION. A `number` parameter is a double. The wrapper's +// `fptosi double %arg to i64` truncated a fractional argument on entry, so +// the whole body ran on the wrong value — and `sitofp` on the way out +// cannot represent a fractional result at all. +// +// Self-recursion is what makes the specialized body actually reachable +// (straight-line callers get HIR-inlined first), so most shapes below recurse. + +// ---- 1. overflow past 2^63: the exact i64 chain wrapped negative ---- +function grow(n: number, acc: number): number { + return n === 0 ? acc : grow(n - 1, acc * 3 + 1); +} +console.log("grow40:", grow(40, 1)); + +// ---- 2. overflow past 2^53 but below 2^63: wrong, not negative ---- +console.log("grow35:", grow(35, 1)); +console.log("grow30:", grow(30, 1)); + +// ---- 3. fractional argument truncated on entry ---- +function frac(n: number, acc: number): number { + return n === 0 ? acc : frac(n - 1, acc * 2); +} +console.log("frac:", frac(3, 0.5)); +console.log("fracNeg:", frac(2, -0.25)); +console.log("fracZero:", frac(4, 0.1)); + +// ---- 4. the 2^53 boundary from both sides ---- +function dbl(n: number, acc: number): number { + return n === 0 ? acc : dbl(n - 1, acc * 2); +} +console.log("pow2_52:", dbl(52, 1)); // 2^52 — exact both ways +console.log("pow2_53:", dbl(53, 1)); // 2^53 — the last exact integer +console.log("pow2_54:", dbl(54, 1)); // 2^54 — ulp is 2, still even so exact +console.log("pow2_53_plus:", dbl(53, 3)); // 3 * 2^53 — exact (odd * 2^53) + +function addOne(n: number, acc: number): number { + return n === 0 ? acc : addOne(n - 1, acc + 1); +} +// 2^53 - 2 -> 2^53 - 1 -> 2^53: still exact. +console.log("below53:", addOne(2, 9007199254740990)); +// One step past: JS saturates (2^53 + 1 is not representable), i64 does not. +console.log("across53:", addOne(4, 9007199254740990)); +console.log("across53neg:", addOne(4, -9007199254740994)); + +function subOne(n: number, acc: number): number { + return n === 0 ? acc : subOne(n - 1, acc - 1); +} +console.log("down53:", subOne(4, -9007199254740990)); + +// ---- 5. chains that must stay exact (and, where specialized, stay fast) ---- +function fib(n: number): number { + if (n <= 1) return n; + return fib(n - 1) + fib(n - 2); +} +console.log("fib25:", fib(25)); +console.log("fib40:", fib(40)); + +function fact(n: number, acc: number): number { + return n <= 1 ? acc : fact(n - 1, acc * n); +} +console.log("fact18:", fact(18, 1)); // 6402373705728000 < 2^53 +console.log("fact20:", fact(20, 1)); // 2432902008176640000 — past 2^53 +console.log("fact25:", fact(25, 1)); // past 2^63 + +function sumTo(n: number, acc: number): number { + return n === 0 ? acc : sumTo(n - 1, acc + n); +} +console.log("sumTo1000:", sumTo(1000, 0)); + +function tri(n: number): number { + if (n <= 0) return 0; + return n + tri(n - 1); +} +console.log("tri100:", tri(100)); + +// ---- 6. a non-recursive numeric function reached indirectly ---- +// Straight-line calls are HIR-inlined before the specialization matters; an +// indirect call through a function value is not, so the wrapper's `fptosi` +// is the thing that actually runs. +function mulAdd(x: number, y: number): number { + return x * y + 1; +} +const fns: ((a: number, b: number) => number)[] = [mulAdd]; +console.log("mulAddDirect:", mulAdd(0.5, 3)); +console.log("mulAddIndirect:", fns[0](0.5, 3)); + +function apply2(f: (a: number, b: number) => number, a: number, b: number): number { + return f(a, b); +} +console.log("mulAddApplied:", apply2(mulAdd, 1.5, 2.5)); +console.log("mulAddBig:", apply2(mulAdd, 94906266, 94906266)); + +// ---- 7. comparisons inside a specialized body must see the real values ---- +function pickLarger(a: number, b: number): number { + return a > b ? a : b; +} +console.log("pick:", apply2(pickLarger, 0.5, 0.25)); + +function countDown(n: number, acc: number): number { + return n < 0.5 ? acc : countDown(n - 1, acc + 1); +} +console.log("countDown:", countDown(3.5, 0));