Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
87dbca3
perf(transform): inline safe cross-module function graphs
Aug 26, 2026
eefff89
perf(array): reuse resolved headers across indexed stores
Aug 26, 2026
11a23d8
perf(array): split dynamic canonical read keys
Aug 26, 2026
0c9653f
perf(method): guard synthetic-arguments direct calls
Aug 26, 2026
000b613
perf(method): scalarize length-only arguments bundles
Aug 26, 2026
07e747d
perf(array): call captureless some callbacks directly
Aug 26, 2026
d586c15
perf(method): inline bounded tiny allocation kernels
Aug 26, 2026
a8bc123
perf(inline): optimize functions inside candidate methods
Aug 26, 2026
00b9c47
perf(for-of): preserve Map entry types in function bodies
Aug 27, 2026
a957784
perf(array): trust validated rooted iterator headers
Aug 27, 2026
36ff603
perf(array): establish element shape proofs on demand
Aug 27, 2026
f8a5fa0
perf(array): reuse dynamic all-pointer append proofs
Aug 27, 2026
618b1fa
perf(property): inline dynamic collection size reads
Aug 27, 2026
b2d7a01
perf(compare): inline exact three-byte literal equality
Aug 27, 2026
1f51b45
perf(descriptors): index descriptors by owner instead of scanning eve…
Aug 27, 2026
3860d20
changelog: add fragment for #8875
Aug 27, 2026
d54ecb0
ci: clear the lint gates for #8872
Aug 27, 2026
e0c71c3
style: cargo fmt (rustfmt import wrapping after the new re-export)
Aug 27, 2026
3b45a5b
fix(runtime): match Node fs readFile prototype
Aug 27, 2026
12724f3
chore: name r23 changelog for PR
Aug 27, 2026
9dbfaaa
Merge branch 'p8875' into HEAD
Aug 27, 2026
a2560b5
Merge branch 'p8872' into HEAD
Aug 27, 2026
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
14 changes: 14 additions & 0 deletions changelog.d/8872-ecs-cross-module-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Removed cross-module dispatch and argument-bundle overhead on the ECS command
path. Fourteen general compiler/runtime mechanisms: safe cross-module
free-function graph inlining, resolved Array header reuse across indexed
stores, split dynamic canonical Array read keys, guarded direct calls that
involve synthesized `arguments`, scalarized length-only `arguments` bundles,
direct captureless `Array.some` callbacks, inlined bounded tiny allocation
kernels and function-candidate optimization inside candidate methods,
preserved Map-entry types inside function bodies, trusted validated rooted
iterator headers, on-demand Array element-shape proofs, reused dynamic
all-pointer append proofs, inlined runtime-branded `Map.size`/`Set.size`
reads, and fully inlined equality against exact three-byte string literals.
On the upstream `codehz/ecs` "5k entities: 3 commands each + sync" row the
retained control moved from 8.610 ms to 7.287 ms per operation on the pinned
M1 Mac mini (15/15 paired wins, 30/30 semantic oracles).
56 changes: 56 additions & 0 deletions changelog.d/8875-descriptor-owner-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
Sped up `Object.keys` / `for…in` on arrays, and every GC cycle, by indexing
property descriptors by their owner instead of scanning the whole table.

Descriptors live in two process-global maps keyed by `(owner_address, key)`.
That shape answers "does owner X have key K?" in one lookup, but it cannot
answer "does owner X have **any** descriptors?" — that is a question about a
group of entries, and the map is only indexed by the full pair. So four call
sites answered it the only way that shape allows, by walking every entry and
filtering on the owner:

```rust
property_descriptors.keys().any(|(ptr, _)| *ptr == owner)
```

The cost of enumerating one small array therefore grew with how many
descriptors *every other object in the program* held. The sites:

* `js_object_keys`' array branch, twice — per enumeration, just to decide
whether a per-index `enumerable` check was needed at all;
* `accessor_descriptor_keys_for_obj`, on the own-keys path;
* `transfer_descriptor_owner`, on every `ArrayHeader` growth;
* `scan_descriptor_roots_mut`, on **every GC cycle** — so since the moving
young-gen scavenge became the default (#7019) this was a per-collection tax
proportional to the program's total descriptor count rather than to what
actually moved.

Profiling `claude -p` put 46.6% of main-thread samples in shapes/descriptors,
with a `HashMap` `Keys` iteration the single hottest self-time entry by 4× over
anything else.

`DescriptorTables` now carries `attr_keys_by_owner` / `accessor_keys_by_owner`
mirroring the two maps, so each of those becomes a hash lookup. Measured with
`Object.keys(array)` × 20 000 while unrelated objects hold N descriptors, on an
otherwise idle machine (best of 6 in-process rounds, 15 process runs; `min` is
the steady-state estimate since GC pauses only ever add time):

| descriptors elsewhere | node | before (min/med) | after (min/med) |
|---:|---:|---:|---:|
| 0 | 1 ms | 11 / 31 ms | 8 / 8 ms |
| 1 000 | 1 ms | 15 / 45 ms | 7 / 8 ms |
| 4 000 | 1 ms | 21 / 88 ms | 8 / 8 ms |
| 16 000 | 0 ms | 62 / 226 ms | 7 / 8 ms |

Before scales with descriptors on objects it never touches; after is flat, like
node, and the gap keeps widening with descriptor count. The variance goes too —
after, `min ≈ median` (7 vs 8) where before it was 62 vs 226, because the scan
was dragging the whole descriptor table through cache on every collection.

Also fixes a pre-existing correctness bug that the new tests caught:
`transfer_descriptor_owner` moved descriptors to the new address but never
carried the per-object Bloom summary (`attr_key_bits` / `accessor_key_bits`). A
freshly grown array has a null `meta`, for which
`owner_may_have_descriptor_entries` answers `false` **authoritatively** — so
after an array grew, `Object.keys` and `getOwnPropertyDescriptor` silently lost
every accessor it had. That was equally true before this change: the gate sat in
front of the old scan, so the scan never ran for the new owner either.
3 changes: 3 additions & 0 deletions changelog.d/8877-release-r23-node-function-prototype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Match Node's prototype surface for callback-style native module exports such as `fs.readFile`.
43 changes: 43 additions & 0 deletions crates/perry-codegen/src/codegen/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ use crate::expr::{nanbox_pointer_inline, FnCtx};
use crate::nanbox::double_literal;
use crate::types::{DOUBLE, I32, I64, PTR};

/// Internal-only declared type used by the direct-call clone whose trailing
/// synthetic `arguments` slot carries the already boxed argument count.
/// Source HIR can never name this type: the marker is attached only to a
/// cloned method immediately before codegen.
pub(crate) const SYNTHETIC_ARGUMENTS_LENGTH_TYPE: &str = "__perry_arguments_length_scalar";

/// Additive direct-call ABI for methods proved to observe `arguments` only
/// through exact `.length` reads. The public method keeps its ordinary marked
/// Array/Arguments ABI for runtime dispatch and reflection.
pub(crate) fn arguments_length_method_name(public_name: &str) -> String {
format!("{public_name}$arguments_length")
}

pub(crate) enum ArgumentsCallee<'a> {
Undefined,
FunctionWrapper(&'a str),
Expand Down Expand Up @@ -143,6 +156,36 @@ fn arguments_used_only_for_length(body: &[Stmt], arguments_id: u32) -> bool {
length_reads > 0 && total_uses == length_reads
}

/// Whether a method may expose the scalar-count direct-call clone.
///
/// This is deliberately stricter than the materialization elision above. A
/// user rest parameter still needs its own array, and a nested closure may
/// outlive the direct call, so both shapes remain on the public ABI even when
/// every syntactic use happens to be a `.length` read.
pub(crate) fn method_supports_arguments_length_direct_abi(method: &perry_hir::Function) -> bool {
let Some(synth_param) = method
.params
.last()
.filter(|p| p.arguments_object.is_some())
else {
return false;
};
if method
.params
.iter()
.any(|p| p.is_rest && p.arguments_object.is_none())
{
return false;
}
let mut captured = false;
crate::collectors::for_each_expr_in_stmts(&method.body, &mut |expr| {
if let Expr::Closure { captures, .. } = expr {
captured |= captures.contains(&synth_param.id);
}
});
!captured && arguments_used_only_for_length(&method.body, synth_param.id)
}

fn mapped_arguments_params(params: &[Param]) -> Vec<(u32, u32)> {
params
.iter()
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,7 @@ pub(super) fn compile_closure(
method_param_counts: &cross_module.method_param_counts,
method_has_rest: &cross_module.method_has_rest,
method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments,
method_arguments_length_only: &cross_module.method_arguments_length_only,
imported_func_return_types: &cross_module.imported_func_return_types,
ffi_signatures: &cross_module.ffi_signatures,
ffi_aliases: &cross_module.ffi_aliases,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,7 @@ pub(super) fn compile_module_entry(
method_param_counts: &cross_module.method_param_counts,
method_has_rest: &cross_module.method_has_rest,
method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments,
method_arguments_length_only: &cross_module.method_arguments_length_only,
imported_func_return_types: &cross_module.imported_func_return_types,
ffi_signatures: &cross_module.ffi_signatures,
ffi_aliases: &cross_module.ffi_aliases,
Expand Down Expand Up @@ -1561,6 +1562,7 @@ pub(super) fn compile_module_entry(
method_param_counts: &cross_module.method_param_counts,
method_has_rest: &cross_module.method_has_rest,
method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments,
method_arguments_length_only: &cross_module.method_arguments_length_only,
imported_func_return_types: &cross_module.imported_func_return_types,
ffi_signatures: &cross_module.ffi_signatures,
ffi_aliases: &cross_module.ffi_aliases,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,7 @@ pub(super) fn compile_function(
method_param_counts: &cross_module.method_param_counts,
method_has_rest: &cross_module.method_has_rest,
method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments,
method_arguments_length_only: &cross_module.method_arguments_length_only,
imported_func_return_types: &cross_module.imported_func_return_types,
ffi_signatures: &cross_module.ffi_signatures,
ffi_aliases: &cross_module.ffi_aliases,
Expand Down
47 changes: 36 additions & 11 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ pub(super) fn compile_method(
method.name
)
})?;
let arguments_length_clone = method.params.last().is_some_and(|param| {
matches!(
&param.ty,
perry_hir::types::Type::Named(name)
if name == super::arguments::SYNTHETIC_ARGUMENTS_LENGTH_TYPE
)
});
// Representation-selection Phase 5a: the proven-`this` clone is a SECOND,
// additive body compiled from the same HIR through the same statement
// lowerer. It never replaces the public symbol and never participates in
Expand All @@ -82,14 +89,15 @@ pub(super) fn compile_method(
.get(&(class.name.clone(), method.name.clone()))
})
.flatten();
let guarded_undefined_param = (!is_index_clone && !ptr_array_cache_clone && !pshape_arg_clone)
.then(|| {
cross_module
.guarded_undefined_method_params
.get(&(class.name.clone(), method.name.clone()))
.copied()
})
.flatten();
let guarded_undefined_param =
(!arguments_length_clone && !is_index_clone && !ptr_array_cache_clone && !pshape_arg_clone)
.then(|| {
cross_module
.guarded_undefined_method_params
.get(&(class.name.clone(), method.name.clone()))
.copied()
})
.flatten();
let fast_array_param_ids = if fast_array_handle_clone {
crate::codegen::typed_abi::nonnegative_index_fast_array_params(
method,
Expand All @@ -110,7 +118,9 @@ pub(super) fn compile_method(
debug_assert!(!pshape_arg_clone || !ptr_array_cache_clone);
debug_assert!(!pshape_arg_clone || typed_public_trampoline.is_none());
debug_assert!(!pshape_arg_clone || !force_generic_body);
let family_name = if pshape_arg_clone {
let family_name = if arguments_length_clone {
super::arguments::arguments_length_method_name(&public_llvm_name)
} else if pshape_arg_clone {
crate::collectors::pshape_args_method_name(&public_llvm_name)
} else if ptr_array_cache_clone {
crate::collectors::ptr_array_cache_method_name(&public_llvm_name)
Expand All @@ -119,7 +129,9 @@ pub(super) fn compile_method(
} else {
public_llvm_name.clone()
};
let llvm_name = if fast_array_handle_clone {
let llvm_name = if arguments_length_clone {
family_name.clone()
} else if fast_array_handle_clone {
crate::codegen::nonnegative_index_fast_array_method_name(
&public_llvm_name,
nonnegative_index_params.expect("fast-array clone has index parameters"),
Expand Down Expand Up @@ -174,6 +186,13 @@ pub(super) fn compile_method(
if is_index_clone {
lf.pre_statepoint_inline = true;
}
// #8872: methods participate in the same allocation-hot analysis as
// functions and closures. This must be set before the entry block exists
// because `lower_call/new_alloc.rs` consults it while lowering each `new`
// site. Previously `collect_alloc_hot_functions` could discover a method
// FuncId, but method codegen silently discarded the result, leaving tiny
// cross-module allocation kernels on the outlined runtime allocator.
lf.alloc_hot = cross_module.alloc_hot_functions.contains(&method.id);

// gh #6206 / #6081: methods were compiled WITHOUT a shadow frame — same
// exact-roots liveness hole as closures (see compile_closure). One extra
Expand Down Expand Up @@ -439,6 +458,7 @@ pub(super) fn compile_method(
method_param_counts: &cross_module.method_param_counts,
method_has_rest: &cross_module.method_has_rest,
method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments,
method_arguments_length_only: &cross_module.method_arguments_length_only,
imported_func_return_types: &cross_module.imported_func_return_types,
ffi_signatures: &cross_module.ffi_signatures,
ffi_aliases: &cross_module.ffi_aliases,
Expand Down Expand Up @@ -1221,7 +1241,11 @@ pub(super) fn compile_method(
// twice.
if let Some(param_index) = guarded_undefined_param.filter(|_| !guarded_undefined_clone) {
emit_guarded_undefined(llmod, method, &family_name, &llvm_name, param_index);
} else if !is_pshape_clone && !is_index_clone && !guarded_undefined_clone {
} else if !arguments_length_clone
&& !is_pshape_clone
&& !is_index_clone
&& !guarded_undefined_clone
{
if let Some(kind) = typed_public_trampoline {
emit_public_typed(llmod, method, &public_llvm_name, &llvm_name, kind);
} else if force_generic_body {
Expand Down Expand Up @@ -1732,6 +1756,7 @@ pub(super) fn compile_static_method(
method_param_counts: &cross_module.method_param_counts,
method_has_rest: &cross_module.method_has_rest,
method_has_synthetic_arguments: &cross_module.method_has_synthetic_arguments,
method_arguments_length_only: &cross_module.method_arguments_length_only,
imported_func_return_types: &cross_module.imported_func_return_types,
ffi_signatures: &cross_module.ffi_signatures,
ffi_aliases: &cross_module.ffi_aliases,
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/codegen/method_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,15 @@ pub(crate) fn build_method_names(
let clone = crate::collectors::pshape_method_name(&llvm_fn);
llmod.declare_function(&clone, DOUBLE, &param_types);
}
if ic
.method_arguments_length_only
.get(method_idx)
.copied()
.unwrap_or(false)
{
let clone = super::arguments::arguments_length_method_name(&llvm_fn);
llmod.declare_function(&clone, DOUBLE, &param_types);
}
}

// Cross-module getters. The dispatch site at
Expand Down
20 changes: 19 additions & 1 deletion crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1549,6 +1549,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
std::collections::HashMap::new();
let mut method_has_synthetic_arguments: std::collections::HashMap<(String, String), bool> =
std::collections::HashMap::new();
let mut method_arguments_length_only: std::collections::HashMap<(String, String), bool> =
std::collections::HashMap::new();
for cls in &hir.classes {
for m in &cls.methods {
let key = (cls.name.clone(), m.name.clone());
Expand All @@ -1561,7 +1563,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
.last()
.is_some_and(|param| param.arguments_object.is_some())
{
method_has_synthetic_arguments.insert(key, true);
method_has_synthetic_arguments.insert(key.clone(), true);
}
if arguments::method_supports_arguments_length_direct_abi(m) {
method_arguments_length_only.insert(key, true);
}
}
// Issue #894: track static methods too. Effect's `static pipe()` /
Expand Down Expand Up @@ -1622,6 +1627,18 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
.insert((effective_name.clone(), mname.clone()), true);
}
}
if ic
.method_arguments_length_only
.get(i)
.copied()
.unwrap_or(false)
{
method_arguments_length_only.insert((ic.name.clone(), mname.clone()), true);
if effective_name != ic.name {
method_arguments_length_only
.insert((effective_name.clone(), mname.clone()), true);
}
}
}
for (i, method_name) in ic.static_method_names.iter().enumerate() {
let registry_name = static_method_registry_key(method_name);
Expand Down Expand Up @@ -2333,6 +2350,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
method_param_counts,
method_has_rest,
method_has_synthetic_arguments,
method_arguments_length_only,
class_keys_globals: class_keys_globals_map,
class_field_counts: class_field_counts_map,
class_init_chains: class_init_chains_map,
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,12 @@ pub struct ImportedClass {
/// `arguments`. Unlike a user `...rest` slot, this slot receives every
/// actual argument while the named parameters remain positional.
pub method_has_synthetic_arguments: Vec<bool>,
/// Parallel to `method_names`. `true` is a producer-authored capability:
/// the method has no user rest parameter and observes its synthesized
/// `arguments` binding only through exact `.length` reads. Importers may
/// call the additive `$arguments_length` ABI with a scalar count instead
/// of allocating and filling an argument bundle.
pub method_arguments_length_only: Vec<bool>,
/// Static field names defined on this class. Used to declare the foreign
/// `@perry_static_<src>__<class>__<field>` global with external linkage
/// so cross-module `[Parent.Symbol.X] = …` reads/writes resolve to the
Expand Down Expand Up @@ -839,6 +845,9 @@ pub(crate) struct CrossModuleCtx {
/// synthetic slot receives all actual arguments rather than only the
/// values after the visible parameters.
pub method_has_synthetic_arguments: std::collections::HashMap<(String, String), bool>,
/// Producer-proved scalar-count direct-call capability for synthetic
/// `arguments` methods. Sparse map (only `true` entries stored).
pub method_arguments_length_only: std::collections::HashMap<(String, String), bool>,
/// Per-class `keys_array` global variable names. Each entry maps
/// `class_name → @perry_class_keys_<modprefix>__<sanitized_class>`.
/// Built once in `compile_module` (one entry per class — local
Expand Down
Loading
Loading