Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions benchmarks/generic-overhead/README.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions benchmarks/generic-overhead/census.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 10 additions & 0 deletions benchmarks/generic-overhead/probes.ts
Original file line number Diff line number Diff line change
@@ -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); }
11 changes: 11 additions & 0 deletions changelog.d/10264-generic-function-overhead.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/codegen/static_fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions crates/perry-codegen/src/expr/generic_overhead_tests.rs
Original file line number Diff line number Diff line change
@@ -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<Param>, body: Vec<Stmt>) -> 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}"
);
}
}
10 changes: 5 additions & 5 deletions crates/perry-codegen/src/expr/literals_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -772,7 +772,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
} 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() {
Expand Down Expand Up @@ -879,7 +879,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
} 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)
Expand Down
12 changes: 6 additions & 6 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/expr/static_field_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -73,7 +73,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// (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
Expand Down
Loading
Loading