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);