diff --git a/benchmarks/generic-overhead/README.md b/benchmarks/generic-overhead/README.md new file mode 100644 index 0000000000..ea0ecfac51 --- /dev/null +++ b/benchmarks/generic-overhead/README.md @@ -0,0 +1,97 @@ +# Generic function overhead + +These exported probes measure the fixed instruction cost of local copies, +registered global stores, and calls through a function parameter. They count +ARM64 machine instructions after LLVM optimization, including cold blocks and +excluding callees. They do not measure executed instructions or throughput. + +## Reproduce + +Build the reference and candidate compilers from their own checkouts. No runtime +archives are needed for this object-only census. + +```sh +python3 benchmarks/generic-overhead/census.py \ + --before /path/to/reference/perry \ + --after /path/to/candidate/perry \ + --objdump /path/to/llvm-objdump \ + --out /tmp/perry-generic-overhead-census +``` + +The default is Perry's LLVM `-Os` policy. Repeat with `--opt 3` to check `-O3`. +Each arm retains the compiler log, traced LLVM IR, object, and disassembly. The +JSON report records compiler and source hashes alongside the instruction counts. + +## Measured instruction counts + +Measured against `4945fc1f7498debc76e9f861d7cf1517da5e67c9` on an Apple M1 Max, +using LLVM 22.1.4 and Perry's `apple-m1` target. Both columns count the same +exported probe source; the callback and unknown-value store are controls. + +| Probe | `-Os` before → after | `-O3` before → after | +| --- | ---: | ---: | +| Identity | 3 → 3 | 3 → 3 | +| One local alias | 21 → 3 | 24 → 3 | +| Three local aliases | 43 → 3 | 58 → 3 | +| Dead inert assignments | 43 → 3 | 58 → 3 | +| Alias live across a call | 70 → 54 | 79 → 55 | +| Constant global write | 11 → 6 | 12 → 7 | +| Declared-number global write wrapper | 17 → 17 | 22 → 22 | +| Unknown-value global write fallback | 17 → 17 | 17 → 17 | +| Callback through a function parameter | 97 → 97 | 107 → 107 | + +Compiler SHA-256: + +- Reference: `e5bbbf453f5660f53c54e8978ee10569daa00000489b5a5be09fd61ad7ee42e5` +- Candidate: `39f2a71d54fee3ca253caa3a372121e49453f423dad356ca153acf0fd3562a72` +- Probe source: `fcdab55841ae899da01a6b6d32f1f753ff753f1706dd5e12765a1eddd0b973fc` + +## Optimization boundaries + +- Copy cleanup runs after the HIR shape passes and before codegen emits string + sharing and root shading. It forwards initialized, unwritten local bindings + and removes unused inert stores. It excludes captures, control flow, TDZ + preallocation, parameter defaults, arguments objects, and unsupported HIR + expressions. Effectful discarded assignments keep both their RHS and storage. +- Global and static-field stores reuse the existing construction proof used for + precise local roots. Proven scalar stores remain stores to registered roots; + unknown values and declared-only numeric parameters retain incremental shading. + +This does not change leaf-root laundering, rest/arguments materialization, +callback dispatch, closure allocation/boxing, borrowed global-string reads, or +cross-module GC effect propagation. + +## Semantic coverage + +`test-files/test_gap_generic_function_overhead.ts` compares with the pinned Node +oracle. It covers primitive and heap aliases, source writes, retained captures, +mapped arguments, TDZ, effectful assignments, declared-only number stores, +ordinary/arrow/bound/rest/proxy callbacks, exception propagation, and an object +alias kept live across allocating calls. Run the same executable under moving +GC stress and require nonzero copying collections and moved objects: + +```sh +PERRY_GC_SCHEDULE_SEED=37 PERRY_GC_SCHEDULE_RATE=0.2 \ +PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 \ +PERRY_GC_VERIFY_EVACUATION=1 /path/to/compiled/fixture +``` + +The crate unit tests separately assert removed work and required fallback IR; +an optimized-away fixture cannot satisfy those emission checks. + +The final fixture matches Node 26.5.1 in both native and shadow-root modes, +normally and under the stress settings above. Each stress run performs 1,411 +copying minor collections and moves 91,275 objects. The shadow IR passes both +the moving-root dominance check (180 root stores) and unrooted-alloca check +(162 GC-capable allocas), with zero violations. + +## Callback follow-up + +The callback probe is an unchanged control. A trial that routed function-typed +locals through `js_closure_call1_receiverless` reduced its caller from 97 to 9 +instructions, but increased median CPU time for ordinary callbacks by 54%. +Arrow callbacks improved by 11%. This used five interleaved before/after pairs +of 20 million calls on the same host and matching runtime build profiles. +The trial moved receiver save/root/restore work into the runtime, so the smaller +caller alone was insufficient evidence. That routing change is excluded; optimizing +the ordinary-function runtime path remains follow-up work. diff --git a/benchmarks/generic-overhead/census.py b/benchmarks/generic-overhead/census.py new file mode 100644 index 0000000000..e245bb0969 --- /dev/null +++ b/benchmarks/generic-overhead/census.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Compile the same probes with two Perry builds and count ARM64 instructions. + +Counts include cold blocks and exclude callees. They measure static code, not +executed instructions or elapsed time. Each arm keeps its IR and disassembly. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + + +def census(compiler: Path, output: Path, objdump: str, opt: str) -> dict: + output.mkdir(parents=True, exist_ok=True) + source = Path(__file__).with_name("probes.ts").resolve() + env = dict(os.environ, PERRY_NO_AUTO_OPTIMIZE="1", PERRY_LL_OPT_LEVEL=opt) + with (output / "compile.log").open("w") as log: + subprocess.run( + [str(compiler), "compile", str(source), "--no-link", "--no-codegen", + "--trace", "llvm", "-o", str(output / "probes.o")], + cwd=output, env=env, stdout=log, stderr=subprocess.STDOUT, check=True, + ) + asm = subprocess.check_output( + [objdump, "-dr", "--no-show-raw-insn", str(output / "probes.o")], text=True, + ) + if "file format mach-o arm64" not in asm and "file format elf64-littleaarch64" not in asm: + raise RuntimeError("This instruction census expects an ARM64 object") + (output / "probes.asm").write_text(asm) + functions = {} + name = None + for line in asm.splitlines(): + label = re.match(r"^[0-9a-f]+ <(.+)>:$", line) + if label: + symbol = label[1].lstrip("_") + name = symbol.split("__", 1)[1] if symbol.startswith("perry_fn_probes_ts__") else None + if name is not None: + functions[name] = 0 + elif name is not None and re.match(r"^\s+[0-9a-f]+:\s+[a-z][a-z0-9.]*\s", line + " "): + functions[name] += 1 + if not functions: + raise RuntimeError("No probe functions found in disassembly") + return {"compiler": str(compiler), "sha256": hashlib.sha256(compiler.read_bytes()).hexdigest(), + "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "opt": opt, "static_instructions": functions} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--before", type=Path, required=True) + parser.add_argument("--after", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--objdump", default="llvm-objdump") + parser.add_argument("--opt", choices=["s", "3"], default="s") + args = parser.parse_args() + output = args.out.resolve() + result = {arm: census(getattr(args, arm).resolve(), output / arm, args.objdump, args.opt) + for arm in ["before", "after"]} + report = json.dumps(result, indent=2) + "\n" + (output / "census.json").write_text(report) + print(report, end="") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/generic-overhead/probes.ts b/benchmarks/generic-overhead/probes.ts new file mode 100644 index 0000000000..5a37cb3c18 --- /dev/null +++ b/benchmarks/generic-overhead/probes.ts @@ -0,0 +1,10 @@ +// Exported bodies are the subjects of the machine-instruction census. +export function identity(x: any): any { return x; } +export function aliasOne(x: any): any { const y = x; return y; } +export function aliasThree(x: any): any { const a = x; const b = a; const c = b; return c; } +export function deadAssignments(x: any): any { let y = x; y = x; y = x; return x; } +export function aliasAcrossCall(x: any, visit: () => void): any { const y = x; visit(); return y; } +let counter: number = 0; +export function globalConstantWrite(): void { counter = 3; } +export function globalWrite(x: number): void { counter = x; } +export function callback(f: (x: number) => number, x: number): number { return f(x); } diff --git a/changelog.d/10264-generic-function-overhead.md b/changelog.d/10264-generic-function-overhead.md new file mode 100644 index 0000000000..015a95414b --- /dev/null +++ b/changelog.d/10264-generic-function-overhead.md @@ -0,0 +1,11 @@ +Remove redundant local copies and unread inert assignments from eligible +straight-line synchronous functions before codegen emits string-sharing and +root-shading work. Global and static-field stores now omit incremental root +shading for values proven scalar by construction; unknown and heap values +retain their barriers. + +On the ARM64 instruction probes at the default LLVM `-Os` level, three local +aliases drop from 43 to 3 instructions, an alias live across a call from 70 to +54, and a constant global write from 11 to 6. These are static code counts, +not throughput measurements. Adds unit tests, Node-parity and moving-GC +coverage, and a reproducible instruction census. diff --git a/crates/perry-codegen/src/codegen/static_fields.rs b/crates/perry-codegen/src/codegen/static_fields.rs index 2d8e84ff5a..9a79c28d05 100644 --- a/crates/perry-codegen/src/codegen/static_fields.rs +++ b/crates/perry-codegen/src/codegen/static_fields.rs @@ -350,7 +350,7 @@ pub(super) fn init_static_fields_late( } let v = v?; let g_ref = format!("@{}", global_name); - crate::expr::emit_root_nanbox_store_on_block(ctx.block(), &v, &g_ref); + crate::expr::emit_root_nanbox_store_for_expr(ctx, &v, &g_ref, init_expr); emit_static_field_registration(ctx, &v); } // Uninitialized non-computed static fields are now registered in diff --git a/crates/perry-codegen/src/expr/generic_overhead_tests.rs b/crates/perry-codegen/src/expr/generic_overhead_tests.rs new file mode 100644 index 0000000000..32d779ab98 --- /dev/null +++ b/crates/perry-codegen/src/expr/generic_overhead_tests.rs @@ -0,0 +1,126 @@ +//! Pin both the removed generic work and the fallback that remains necessary. + +use crate::testing::root_slots::function_slice; +use crate::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{CompareOp, Expr, Function, Module, Param, Stmt}; + +fn param(id: u32, ty: Type) -> Param { + Param { + id, + name: format!("p{id}"), + ty, + default: None, + decorators: vec![], + is_rest: false, + arguments_object: None, + } +} + +fn probe_ir(params: Vec, body: Vec) -> String { + let mut module = Module::new("cost_test"); + module.init.push(Stmt::Let { + id: 99, + name: "sink".into(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Number(0.0)), + }); + module.functions.push(Function { + id: 1, + name: "probe".into(), + type_params: vec![], + params, + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: true, + captures: vec![], + decorators: vec![], + was_plain_async: false, + was_unrolled: false, + }); + let ir = String::from_utf8( + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + is_entry_module: false, + ..CompileOptions::default() + }, + ) + .expect("compile cost probe"), + ) + .unwrap(); + let mut body = function_slice(&ir, "perry_fn_cost_test__probe").to_string(); + // A declared numeric parameter can introduce a guarded ABI wrapper. The + // erased-type fallback, not the wrapper's dispatch, owns its root store. + let generic = "perry_fn_cost_test__probe$generic"; + if ir.contains(&format!("@{generic}(")) { + body.push_str(function_slice(&ir, generic)); + } + body +} + +fn global_store_ir(value: Expr, ty: Type) -> String { + probe_ir( + vec![param(1, ty)], + vec![ + Stmt::Expr(Expr::LocalSet(99, Box::new(value))), + Stmt::Return(Some(Expr::LocalGet(99))), + ], + ) +} + +#[test] +fn scalar_global_stores_keep_the_store_without_root_shading() { + for value in [ + Expr::Number(3.0), + Expr::Number(-0.0), + Expr::Number(f64::NAN), + Expr::Integer(7), + Expr::Bool(true), + Expr::Null, + Expr::Undefined, + Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(1)), + right: Box::new(Expr::Null), + }, + ] { + let ir = global_store_ir(value, Type::Any); + assert!( + ir.lines().any(|line| line.contains("store double") + && line.contains("@perry_global_cost_test__99")), + "store disappeared:\n{ir}" + ); + assert!( + !ir.contains("call void @js_write_barrier_root_nanbox("), + "scalar barrier:\n{ir}" + ); + } +} + +#[test] +fn unknown_and_declared_number_globals_keep_root_shading() { + for ty in [Type::Any, Type::Number] { + let ir = global_store_ir(Expr::LocalGet(1), ty); + assert!( + ir.contains("call void @js_write_barrier_root_nanbox("), + "annotation must not suppress the barrier:\n{ir}" + ); + } + for value in [ + Expr::String("a heap string longer than SSO".into()), + Expr::Array(vec![Expr::Number(3.0)]), + Expr::BigInt("123".into()), + ] { + let ir = global_store_ir(value, Type::Any); + assert!( + ir.contains("call void @js_write_barrier_root_nanbox("), + "heap barrier missing:\n{ir}" + ); + } +} diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index 007ac9c00b..04bafe41e0 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -17,9 +17,9 @@ use crate::type_analysis::{is_map_expr, is_set_expr, receiver_class_name}; use crate::types::{DOUBLE, I32, I64}; use super::{ - can_lower_expr_as_i32_in_current_region, emit_root_nanbox_store_on_block, - emit_shadow_slot_clear, emit_shadow_slot_update_for_expr, emit_write_barrier, - is_global_this_builtin_function_name, lower_expr, lower_expr_as_i32, + can_lower_expr_as_i32_in_current_region, emit_root_nanbox_store_for_expr, + emit_root_nanbox_store_on_block, emit_shadow_slot_clear, emit_shadow_slot_update_for_expr, + emit_write_barrier, is_global_this_builtin_function_name, lower_expr, lower_expr_as_i32, lower_pod_local_reassignment, materialize_pod_value_copy, nanbox_string_inline, FnCtx, TrustedBoxCapturePtr, }; @@ -772,7 +772,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if let Some(global_name) = ctx.module_globals.get(id).cloned() { let g_ref = format!("@{}", global_name); // GC_STORE_AUDIT(ROOT): module global slot is registered as a mutable GC root. - emit_root_nanbox_store_on_block(ctx.block(), &v_dbl, &g_ref); + emit_root_nanbox_store_for_expr(ctx, &v_dbl, &g_ref, value); } if !is_canonical { if let Some(slot_idx) = ctx.shadow_slot_map.get(id).copied() { @@ -879,7 +879,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if let Some(global_name) = ctx.module_globals.get(id).cloned() { let g_ref = format!("@{}", global_name); // GC_STORE_AUDIT(ROOT): module global slot is registered as a mutable GC root. - emit_root_nanbox_store_on_block(ctx.block(), &v, &g_ref); + emit_root_nanbox_store_for_expr(ctx, &v, &g_ref, value); } super::record_native_arena_owner_assignment(ctx, *id, value.as_ref()); if ctx.receiver_descriptors.contains_buffer_view(id) diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 910ab2a688..cef3f103ff 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -136,12 +136,12 @@ pub(crate) use write_barrier::{ emit_jsvalue_slot_store_pointer_tested, emit_jsvalue_slot_store_scalar_aware_on_block, emit_jsvalue_slot_store_with_flags_on_block, emit_jsvalue_slot_store_with_value_bits_on_block, emit_layout_note_slot_on_block, emit_may_carry_heap_pointer_check, - emit_root_heap_word_store_on_block, emit_root_nanbox_store_on_block, - emit_scalar_aware_store_gated_on_pointerness, emit_write_barrier, - emit_write_barrier_slot_generation_tested, emit_write_barrier_slot_on_block, - emit_write_barrier_slot_value_and_generation_tested, lower_array_super_init, - lower_event_emitter_async_resource_subclass_init, lower_event_emitter_subclass_init, - lower_node_stream_super_init, lower_stream_super_init, + emit_root_heap_word_store_on_block, emit_root_nanbox_store_for_expr, + emit_root_nanbox_store_on_block, emit_scalar_aware_store_gated_on_pointerness, + emit_write_barrier, emit_write_barrier_slot_generation_tested, + emit_write_barrier_slot_on_block, emit_write_barrier_slot_value_and_generation_tested, + lower_array_super_init, lower_event_emitter_async_resource_subclass_init, + lower_event_emitter_subclass_init, lower_node_stream_super_init, lower_stream_super_init, }; // Issue #1098 phase 3: the `FnCtx` definition stays in this trunk, but its diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 421c055eb6..ca7cf2391c 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -13,7 +13,7 @@ use crate::rooting::{ }; use crate::types::{DOUBLE, I32, I64, PTR}; -use super::{emit_root_nanbox_store_on_block, lower_expr, nanbox_pointer_inline, FnCtx}; +use super::{emit_root_nanbox_store_for_expr, lower_expr, nanbox_pointer_inline, FnCtx}; /// The compiled symbols for `template`'s `static { … }` blocks, in declaration /// order (#685). @@ -73,7 +73,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // (register_module_globals_as_gc_roots walks ctx.static_field_globals since the // 2026-07-02 audit fix; before that this comment was aspirational and the slot // was unrooted). - emit_root_nanbox_store_on_block(ctx.block(), &v, &g_ref); + emit_root_nanbox_store_for_expr(ctx, &v, &g_ref, value); } // v0.5.747: also register the static field in the runtime // CLASS_DYNAMIC_PROPS side-table so dynamic-dispatch reads diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index 01f8adf96e..f16302a85e 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -11,6 +11,10 @@ use crate::nanbox::double_literal; use crate::native_value::LoweredValue; use crate::types::{DOUBLE, I1, I16, I32, I64, I8}; +#[cfg(test)] +#[path = "generic_overhead_tests.rs"] +mod generic_overhead_tests; + /// `GC_LAYOUT_STATE_MASK | GC_OBJ_TYPED_LAYOUT_INTACT` (`0xD000`) as a signed /// i16 — the emitted IR is textual, so the constant is written the way LLVM /// parses an i16 literal (mirrors `class_field_inline_guard`'s convention). @@ -329,6 +333,24 @@ pub(crate) fn emit_write_barrier_slot_value_and_generation_tested( ctx.current_block = done_idx; } +/// Use the same construction proof as precise local roots. A scalar cannot +/// introduce a new heap edge during incremental marking; the registered root +/// slot still receives the store, including overwrites of old heap values. +/// TypeScript annotations alone do not satisfy this proof. +pub(crate) fn emit_root_nanbox_store_for_expr( + ctx: &mut FnCtx<'_>, + value: &str, + root_slot: &str, + expr: &Expr, +) { + if super::expr_is_known_non_pointer_shadow_value(ctx, expr) { + // GC_STORE_AUDIT(ROOT): proven scalar in a registered mutable root. + ctx.block().store(DOUBLE, value, root_slot); + } else { + emit_root_nanbox_store_on_block(ctx.block(), value, root_slot); + } +} + pub(crate) fn emit_root_nanbox_store_on_block(blk: &mut LlBlock, value: &str, root_slot: &str) { // GC_STORE_AUDIT(ROOT): module-global slot registered as a mutable GC // root; the root-barrier call below covers incremental marking. diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 288e799086..251f4bbd8a 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -8,7 +8,7 @@ use super::let_stmt_facts::{ use super::unused_expr::lower_unused_expr; use super::*; use crate::expr::{ - box_i1_for_compat_shadow, emit_root_nanbox_store_on_block, + box_i1_for_compat_shadow, emit_root_nanbox_store_for_expr, expr_produces_non_pointer_bits_by_construction, lower_expr_value, lower_expr_with_expected_type, unbox_str_handle, }; @@ -1167,7 +1167,7 @@ pub(crate) fn lower_let( if let Some(init_expr) = init { let v = lower_expr_with_expected_type(ctx, init_expr, Some(&refined_ty))?; let g_ref = format!("@{}", global_name); - emit_root_nanbox_store_on_block(ctx.block(), &v, &g_ref); + emit_root_nanbox_store_for_expr(ctx, &v, &g_ref, init_expr); // Buffer data-pointer slot: when the HIR facts identify a fresh // immutable u8 buffer, pre-compute the data base pointer (handle + diff --git a/crates/perry-transform/src/lib.rs b/crates/perry-transform/src/lib.rs index 26cdb1dddb..c6fcfc91e9 100644 --- a/crates/perry-transform/src/lib.rs +++ b/crates/perry-transform/src/lib.rs @@ -16,6 +16,7 @@ pub mod finally_inline; pub mod generator; pub mod i18n; pub mod inline; +mod local_copies; pub mod module_const_fold; pub mod prop_cse; mod source_spans; @@ -56,4 +57,7 @@ pub fn post_inline_cleanups(module: &mut perry_hir::Module) { closure_local_inline::run(module); field_push_local_bind::run(module); prop_cse::run(module); + // Let the shape-specific passes consume their bindings first, then remove + // plain copies before codegen attaches string-sharing and root barriers. + local_copies::run(module); } diff --git a/crates/perry-transform/src/local_copies.rs b/crates/perry-transform/src/local_copies.rs new file mode 100644 index 0000000000..5461ba4dcd --- /dev/null +++ b/crates/perry-transform/src/local_copies.rs @@ -0,0 +1,230 @@ +//! Remove copy bookkeeping before codegen turns it into observable runtime +//! calls (string sharing and incremental root shading). +//! +//! This first pass handles straight-line, synchronous function bodies. An +//! alias must copy an initialized, unwritten local, and itself have no writes. +//! Closure/class creation, control flow, TDZ preallocation, parameter defaults +//! and arguments objects reject the body. Unrecognized expression forms, +//! including with and LocalId-bearing intrinsics, also reject it. Constant +//! eval already lowered to ordinary local reads/writes follows the same rules. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::types::LocalId; +use perry_hir::walker::{walk_expr_children, walk_expr_children_mut}; +use perry_hir::{Expr, Function, Module, Stmt}; + +pub fn run(module: &mut Module) { + // Retained function bodies can refer to a sibling's locals. Never change + // the storage named in their capture metadata, even for hand-built HIR. + let mut captured = HashSet::new(); + for f in &module.functions { + captured.extend(f.captures.iter().copied()); + } + for class in &module.classes { + for f in class + .constructor + .iter() + .chain(&class.methods) + .chain(&class.static_methods) + .chain(class.getters.iter().map(|(_, f)| f)) + .chain(class.setters.iter().map(|(_, f)| f)) + .chain(class.computed_members.iter().map(|m| &m.function)) + { + captured.extend(f.captures.iter().copied()); + } + } + // Module-init bindings are registered globals, not function-local copies. + for f in &mut module.functions { + loop { + let before = f.body.len(); + clean_function(f, &captured); + if f.body.len() == before { + break; + } + } + } +} + +#[derive(Default)] +struct Uses { + reads: HashSet, + writes: HashSet, +} + +fn inspect_expr(expr: &Expr, uses: &mut Uses) -> bool { + match expr { + Expr::LocalGet(id) => { + uses.reads.insert(*id); + } + Expr::LocalSet(id, _) | Expr::Update { id, .. } => { + uses.writes.insert(*id); + if matches!(expr, Expr::Update { .. }) { + uses.reads.insert(*id); + } + } + Expr::Undefined + | Expr::Null + | Expr::Bool(_) + | Expr::Number(_) + | Expr::Integer(_) + | Expr::String(_) + | Expr::WtfString(_) + | Expr::BigInt(_) + | Expr::GlobalGet(_) + | Expr::GlobalSet(_, _) + | Expr::FuncRef(_) + | Expr::ExternFuncRef { .. } + | Expr::This + | Expr::Binary { .. } + | Expr::Unary { .. } + | Expr::Compare { .. } + | Expr::Logical { .. } + | Expr::Conditional { .. } + | Expr::Sequence(_) + | Expr::TypeOf(_) + | Expr::Void(_) + | Expr::Call { .. } + | Expr::PropertyGet { .. } + | Expr::PropertySet { .. } + | Expr::IndexGet { .. } + | Expr::IndexSet { .. } + | Expr::Array(_) => {} + _ => return false, + } + let mut supported = true; + walk_expr_children(expr, &mut |e| supported &= inspect_expr(e, uses)); + supported +} + +fn clean_function(f: &mut Function, captured: &HashSet) { + if f.is_async + || f.is_generator + || !f.captures.is_empty() + || f.params.iter().any(|p| { + p.default.is_some() || p.arguments_object.is_some() || !p.decorators.is_empty() + }) + { + return; + } + let mut uses = Uses::default(); + let mut declared: HashSet<_> = f.params.iter().map(|p| p.id).collect(); + let mut mutable = HashSet::new(); + for stmt in &f.body { + let expr = match stmt { + Stmt::Let { + id, + mutable: m, + init, + .. + } => { + if !declared.insert(*id) { + return; + } + if *m { + mutable.insert(*id); + } + init.as_ref() + } + Stmt::Expr(e) | Stmt::Throw(e) => Some(e), + Stmt::Return(e) => e.as_ref(), + _ => return, + }; + if expr.is_some_and(|e| !inspect_expr(e, &mut uses)) { + return; + } + } + if !declared.is_disjoint(captured) { + return; + } + // Do not rely on the lowering pass having already inserted explicit TDZ + // boxes. Reject forward reads/writes before changing any declaration. + let mut available: HashSet<_> = f.params.iter().map(|p| p.id).collect(); + for stmt in &f.body { + let expr = match stmt { + Stmt::Let { init, .. } | Stmt::Return(init) => init.as_ref(), + Stmt::Expr(e) | Stmt::Throw(e) => Some(e), + _ => unreachable!(), + }; + let mut at_stmt = Uses::default(); + if let Some(expr) = expr { + inspect_expr(expr, &mut at_stmt); + } + if at_stmt + .reads + .union(&at_stmt.writes) + .any(|id| declared.contains(id) && !available.contains(id)) + { + return; + } + if let Stmt::Let { id, .. } = stmt { + available.insert(*id); + } + } + let mut initialized: HashSet<_> = f.params.iter().map(|p| p.id).collect(); + let mut aliases = HashMap::new(); + f.body.retain_mut(|stmt| { + // Rewrite only reads, in source order: earlier reads must retain TDZ + // behavior, and assignment targets must keep their original binding. + let expr = match stmt { + Stmt::Let { init, .. } | Stmt::Return(init) => init.as_mut(), + Stmt::Expr(e) | Stmt::Throw(e) => Some(e), + _ => unreachable!(), + }; + if let Some(expr) = expr { + rewrite_reads(expr, &aliases); + } + match stmt { + Stmt::Let { id, init, .. } => { + let alias = match init { + Some(Expr::LocalGet(src)) + if initialized.contains(src) + && !uses.writes.contains(src) + && !uses.writes.contains(id) => + { + Some(*src) + } + _ => None, + }; + let dead = !uses.reads.contains(id) + && !uses.writes.contains(id) + && init.as_ref().is_none_or(|e| inert(e, &initialized)); + initialized.insert(*id); + if let Some(src) = alias { + aliases.insert(*id, src); + false + } else { + !dead + } + } + Stmt::Expr(Expr::LocalSet(id, value)) => { + !(mutable.contains(id) + && initialized.contains(id) + && !uses.reads.contains(id) + && inert(value, &initialized)) + } + _ => true, + } + }); +} + +fn inert(expr: &Expr, initialized: &HashSet) -> bool { + match expr { + Expr::LocalGet(id) => initialized.contains(id), + Expr::Undefined | Expr::Null | Expr::Bool(_) | Expr::Number(_) | Expr::Integer(_) => true, + _ => false, + } +} + +fn rewrite_reads(expr: &mut Expr, aliases: &HashMap) { + if let Expr::LocalGet(id) = expr { + if let Some(src) = aliases.get(id) { + *id = *src; + } + } + walk_expr_children_mut(expr, &mut |e| rewrite_reads(e, aliases)); +} + +#[cfg(test)] +#[path = "local_copies_tests.rs"] +mod tests; diff --git a/crates/perry-transform/src/local_copies_tests.rs b/crates/perry-transform/src/local_copies_tests.rs new file mode 100644 index 0000000000..dd9e915337 --- /dev/null +++ b/crates/perry-transform/src/local_copies_tests.rs @@ -0,0 +1,225 @@ +use super::*; +use perry_hir::types::Type; +use perry_hir::{ArgumentsObjectMeta, Param}; + +fn function(body: Vec) -> Function { + Function { + id: 1, + name: "copies".into(), + type_params: vec![], + params: vec![Param { + id: 1, + name: "x".into(), + ty: Type::Any, + default: None, + decorators: vec![], + is_rest: false, + arguments_object: None, + }], + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: true, + captures: vec![], + decorators: vec![], + was_plain_async: false, + was_unrolled: false, + } +} + +fn alias(id: u32, src: u32) -> Stmt { + Stmt::Let { + id, + name: format!("v{id}"), + ty: Type::Any, + mutable: true, + init: Some(Expr::LocalGet(src)), + } +} + +fn ret(id: u32) -> Stmt { + Stmt::Return(Some(Expr::LocalGet(id))) +} + +fn set(id: u32, value: Expr) -> Stmt { + Stmt::Expr(Expr::LocalSet(id, Box::new(value))) +} + +fn optimize(f: Function) -> Function { + let mut module = Module::new("copies"); + module.functions.push(f); + run(&mut module); + module.functions.remove(0) +} + +fn unchanged(f: Function) { + let before = format!("{:?}", f.body); + assert_eq!(format!("{:?}", optimize(f).body), before); +} + +#[test] +fn transitive_aliases_become_the_original_value() { + let f = optimize(function(vec![ + alias(2, 1), + alias(3, 2), + alias(4, 3), + ret(4), + ])); + assert!(matches!( + f.body.as_slice(), + [Stmt::Return(Some(Expr::LocalGet(1)))] + )); +} + +#[test] +fn unread_assignments_and_their_declaration_disappear() { + let f = optimize(function(vec![ + alias(2, 1), + set(2, Expr::LocalGet(1)), + set(2, Expr::Number(3.0)), + ret(1), + ])); + assert!(matches!( + f.body.as_slice(), + [Stmt::Return(Some(Expr::LocalGet(1)))] + )); +} + +#[test] +fn source_or_alias_writes_keep_the_copy() { + unchanged(function(vec![ + alias(2, 1), + set(1, Expr::Number(3.0)), + ret(2), + ])); + unchanged(function(vec![ + alias(2, 1), + set(2, Expr::Number(3.0)), + ret(2), + ])); +} + +#[test] +fn discarded_assignments_keep_effectful_rhs_and_storage() { + let call = Expr::Call { + callee: Box::new(Expr::GlobalGet(99)), + args: vec![], + type_args: vec![], + byte_offset: 0, + }; + unchanged(function(vec![alias(2, 1), set(2, call), ret(1)])); + // An assignment nested in another expression also needs the declaration. + unchanged(function(vec![ + alias(2, 1), + Stmt::Expr(Expr::Void(Box::new(Expr::LocalSet( + 2, + Box::new(Expr::Number(3.0)), + )))), + ret(1), + ])); +} + +#[test] +fn unread_const_assignments_still_throw() { + let mut declaration = alias(2, 1); + if let Stmt::Let { mutable, .. } = &mut declaration { + *mutable = false; + } + unchanged(function(vec![ + declaration, + set(2, Expr::LocalGet(1)), + ret(1), + ])); +} + +#[test] +fn forward_reads_and_writes_keep_tdz_behavior() { + unchanged(function(vec![ + Stmt::Expr(Expr::LocalGet(2)), + alias(2, 1), + ret(2), + ])); + unchanged(function(vec![ + set(2, Expr::Number(3.0)), + alias(2, 1), + ret(1), + ])); + unchanged(function(vec![ + Stmt::PreallocateTdzBoxes(vec![2]), + alias(2, 1), + ret(2), + ])); +} + +#[test] +fn arguments_defaults_async_and_captures_are_excluded() { + let f = function(vec![alias(2, 1), ret(2)]); + let mut args = f.clone(); + args.params[0].arguments_object = Some(ArgumentsObjectMeta { + strict: false, + simple_parameters: true, + mapped_parameter_ids: vec![(0, 1)], + restricted_callee: false, + }); + unchanged(args); + let mut defaults = f.clone(); + defaults.params[0].default = Some(Expr::Number(3.0)); + unchanged(defaults); + let mut async_f = f.clone(); + async_f.is_async = true; + unchanged(async_f); + let mut generator = f.clone(); + generator.is_generator = true; + unchanged(generator); + let mut capture = f.clone(); + capture.captures.push(9); + unchanged(capture); + let mut module = Module::new("retained_capture"); + let mut sibling = function(vec![ret(2)]); + sibling.captures.push(2); + module.functions = vec![f.clone(), sibling]; + run(&mut module); + assert_eq!( + format!("{:?}", module.functions[0].body), + format!("{:?}", f.body) + ); +} + +#[test] +fn loops_and_local_id_intrinsics_are_excluded() { + unchanged(function(vec![ + alias(2, 1), + Stmt::While { + condition: Expr::Bool(false), + body: vec![ret(2)], + }, + ret(2), + ])); + unchanged(function(vec![ + alias(2, 1), + Stmt::Expr(Expr::ArrayPop(2)), + ret(2), + ])); +} + +#[test] +fn effectful_use_keeps_evaluation_order_but_loses_the_alias() { + let call = Expr::Call { + callee: Box::new(Expr::LocalGet(1)), + args: vec![Expr::LocalGet(2)], + type_args: vec![], + byte_offset: 17, + }; + let f = optimize(function(vec![alias(2, 1), Stmt::Return(Some(call))])); + assert_eq!(f.body.len(), 1); + let Stmt::Return(Some(Expr::Call { + args, byte_offset, .. + })) = &f.body[0] + else { + panic!() + }; + assert!(matches!(args.as_slice(), [Expr::LocalGet(1)])); + assert_eq!(*byte_offset, 17); +} diff --git a/test-files/test_gap_generic_function_overhead.ts b/test-files/test_gap_generic_function_overhead.ts new file mode 100644 index 0000000000..de65b12a41 --- /dev/null +++ b/test-files/test_gap_generic_function_overhead.ts @@ -0,0 +1,104 @@ +"use strict"; + +// Keep the small functions behind runtime dispatch so inlining cannot hide +// their alias bookkeeping or the closure-typed parameter call under test. +function identity(x: any): any { return x; } +function aliasOne(x: any): any { const y = x; return y; } +function aliasThree(x: any): any { const a = x; const b = a; const c = b; return c; } +function deadAssignments(x: any): any { let y = x; y = x; y = x; return x; } +function changedSource(x: any): any { const y = x; x = "changed"; return y; } +function changedAlias(x: any): any { let y = x; y = "changed"; return y; } +function capturedAlias(x: any): any { const y = x; return () => y; } +function appendedSource(x: any): any { const y = x; x += "-appended"; return y; } +function aliasAcrossCall(x: any, visit: () => void): any { const y = x; visit(); return y; } +const copies: any[] = [identity, aliasOne, aliasThree, deadAssignments, changedSource]; +const values: any[] = [0, -0, NaN, 17, true, null, undefined, + "a string longer than the inline string representation", 12345678901234567890n, + Symbol("value"), { value: 17 }, [1, 2, 3]]; +let copyFailures = 0; +for (let i = 0; i < copies.length; i++) { + for (let j = 0; j < values.length; j++) { + if (!Object.is(copies[i](values[j]), values[j])) copyFailures++; + } +} +console.log("copies", copyFailures, changedAlias("original"), capturedAlias(19)()); +console.log("string-copy", appendedSource("a heap string which must remain unchanged")); + +let effects = 0; +function effect(): any { effects++; return { effects }; } +function deadEffect(x: any): any { let y = x; y = effect(); return x; } +console.log("effects", deadEffect(7), effects); +function tdz(): void { + try { console.log(later); const later = 1; } + catch (e) { console.log("tdz", e instanceof ReferenceError); } +} +tdz(); +// Sloppy mapped arguments can change the source after its alias was copied. +const mapped = Function("x", "const y = x; arguments[0] = 99; return y;"); +console.log("mapped", mapped(7)); + +let sink: any = null; +function storeScalar(): any { sink = 3; return sink; } +function storeUnknown(value: any): any { sink = value; return sink; } +function storeDeclaredNumber(value: number): any { sink = value; return sink; } +const stores: any[] = [storeUnknown, storeDeclaredNumber]; +console.log("scalar", storeScalar()); +let storeFailures = 0; +for (let i = 0; i < stores.length; i++) { + for (let j = 0; j < values.length; j++) { + if (!Object.is(stores[i](values[j]), values[j])) storeFailures++; + } +} +console.log("stores", storeFailures); + +function invoke(callback: (x: any) => any, x: any): any { return callback(x); } +const dispatchers: any[] = [invoke]; +function churn(): number { + const keep: any[] = []; + for (let i = 0; i < 80; i++) keep.push({ i, text: "allocation-" + i, pad: [i] }); + return keep.length; +} +const strictCallback = function (this: any, x: any): any { + "use strict"; + churn(); + return [this === undefined, x]; +}; +const host: any = { + marker: "host", + run: function (this: any, callback: (x: any) => any): any { + const result = dispatchers[0](callback, 23); + return [result, this.marker]; + }, +}; +console.log("strict", JSON.stringify(host.run(strictCallback))); +const lexical: any = { + marker: "lexical", + make: function (this: any): any { return (x: any) => { churn(); return this.marker + x; }; }, +}; +console.log("arrow", JSON.stringify(host.run(lexical.make()))); +const bound = function (this: any, x: any): any { churn(); return this.marker + x; } + .bind({ marker: "bound" }); +console.log("bound", JSON.stringify(host.run(bound))); +const rest = function (this: any, ...xs: any[]): any { + return [this === undefined, xs.length, xs[0]]; +}; +console.log("rest", JSON.stringify(host.run(rest))); +const proxy = new Proxy(strictCallback, { + apply: function (target: any, receiver: any, args: any[]): any { + return [receiver === undefined, Reflect.apply(target, receiver, args)]; + }, +}); +console.log("proxy", JSON.stringify(host.run(proxy))); +try { host.run(function (x: any): any { churn(); throw new Error("callback-" + x); }); } +catch (e: any) { console.log("throw", e.message); } +console.log("after-throw", JSON.stringify(host.run(strictCallback))); +let stressFailures = 0; +const acrossCalls: any[] = [aliasAcrossCall]; +for (let i = 0; i < 40; i++) { + const item: any = { marker: "fresh-" + i, run: host.run }; + const kept = acrossCalls[0](item, churn); + if (kept !== item || kept.marker !== "fresh-" + i) stressFailures++; + const result = item.run(strictCallback); + if (result[0][0] !== true || result[0][1] !== 23 || result[1] !== item.marker) stressFailures++; +} +console.log("stress", stressFailures);