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
2 changes: 1 addition & 1 deletion TYPE_LOWERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ Every allocation is preceded by an 8-byte `GcHeader`: `obj_type` (u8), `gc_flags

### Closures

`ClosureHeader`: `func_ptr` (usize), `capture_count` (u32, high bit = `CAPTURES_THIS_FLAG`), `type_tag` (`CLOSURE_MAGIC 0x434C_4F53`), variadic `captures[]` (u64 slots). Mutable captures are heap-boxed. Side-tables: `CLOSURE_REST_REGISTRY`, `CLOSURE_ARITY_REGISTRY`, `DISPATCH_CACHE`. Public closure dispatch still uses the generic closure pointer plus boxed `double` argument/return model. Eligible typed closure clones now use an internal `i64 this_closure, typed args...` ABI so immutable f64/i1 captures can be loaded as native values before the body is lowered.
`ClosureHeader`: `func_ptr` (usize), `capture_count` (u32, high bit = `CAPTURES_THIS_FLAG`), `type_tag` (`CLOSURE_MAGIC 0x434C_4F53`), variadic `captures[]` (u64 slots). Mutable captures are heap-boxed. Side-table: `CLOSURE_BODY_REGISTRY` (one packed record per body: rest/arity/length/flags, #9707) plus the `DISPATCH_RECENT` four-entry cache. Public closure dispatch still uses the generic closure pointer plus boxed `double` argument/return model. Eligible typed closure clones now use an internal `i64 this_closure, typed args...` ABI so immutable f64/i1 captures can be loaded as native values before the body is lowered.

### Async/Await

Expand Down
50 changes: 50 additions & 0 deletions changelog.d/9722-closure-body-registry-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
**Closure body registries: one packed record per function instead of ten
maps keyed by the same pointer (#9707).**

Module init used to record what it knows about each closure body — rest
arity + kind, declared ABI arity, ECMAScript `.length`, arrow / strict /
async / generator / async-generator flags, the two compiler-private
direct-call bodies of an eligible arrow — into ten separate thread-local
`PtrHashMap`s, every one keyed by the same `func_ptr`, plus an eleventh map
memoizing the dispatch strategy those answers imply. Ten key copies, ten
tables' worth of hashbrown load-factor slack, and a dispatch-strategy miss
that probed up to four of them in sequence. On cc's validated heap census
(~59k functions) the family summed to 7.24 MB with the current estimator
(the 11.8 MB the issue quotes was the earlier one, which double-counted
exactly-sized tables).

`CLOSURE_BODY_REGISTRY` is now the single table: `func_ptr →
ClosureBodyRecord`, a 16-byte record holding `.length`, the declared arity
and the rest arity as integers, and every boolean attribute plus the 2-bit
rest kind as flag bits; `(usize, ClosureBodyRecord)` is a 24-byte bucket,
pinned by a size test. The rare `TrustedDirectTarget` pair (direct-call body
and versioned-loop body) moves to a dense append-only side array
(`TRUSTED_TARGETS`) that only eligible arrows index into, so a body without
them pays four bytes, not two `Option<TrustedDirectTarget>` maps. The
dispatch-strategy cache is deleted outright: a miss now does ONE probe of
the record and derives rest/arity/arrow-ness from its bits, which is cheaper
than the second hash probe the cache cost — and cannot go stale, so the
#6475 late-registration invalidation shrinks to the four-entry
`DISPATCH_RECENT` eviction. Every `js_register_closure_*` entry point and
every `lookup_*` / `is_registered_*` reader keeps its signature; rest still
wins over arity for dispatch and `closure_arity`, and `closure_length` still
prefers the explicit length, then rest, then arity.

Measured with `PERRY_GC_CENSUS` on a generated 20k-function fixture (5k
each of default-param arrows, rest functions, async functions and
generators; 35,051 registered bodies including the runtime's own): the
closure registry rows go from 2,916,564 bytes across seven populated maps to
1,638,416 bytes in one (`closure.body_registry`), −44 %, with byte-identical
program output. Projected onto cc's recorded census counts (59,384 distinct
bodies, 58.5k of them strict, 27k arrows, 6.8k dispatch-cache entries) the
same estimator gives 3.28 MB against the 7.24 MB before, −55 %; the
remaining floor is hashbrown's power-of-two bucket count at that size. The
census now prints `closure.body_registry` and `closure.trusted_targets` in
place of the ten per-attribute rows.

Not in this change: the `fn.name_registry` / `fn.source_registry` tables the
issue mentions (5.4 MB on the same census) keep their own `func_ptr` keying —
folding them in wants the dense function-id scheme, which this does not
introduce. `scripts/gc_runtime_root_holders.json` records the two new
statics under the same `not_a_gc_pointer` verdict the deleted maps carried
(code pointers and plain integers only) and drops the eight stale entries.
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/codegen/string_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ pub(super) fn emit_string_pool(
user_fn_wrapper_rest_and_arguments: &std::collections::HashSet<String>,
// ABI param count for every top-level user-function wrapper
// (`__perry_wrap_<original_name>`) — used to register the wrapper's
// declared arity in the runtime's `CLOSURE_ARITY_REGISTRY` so dynamic
// declared arity in the runtime's closure body registry so dynamic
// dispatch can pad missing trailing args before invoking the wrapper.
// Entries for wrappers also present in `user_fn_wrapper_rest` are skipped
// (those go through the rest registry which already controls dispatch).
Expand Down Expand Up @@ -735,7 +735,7 @@ pub(super) fn emit_string_pool(
// length, which mis-split dynamic-parent (capless-sig-with-snapshot) ctors.
let mut ctor_triples: Vec<(u32, String, u32, u32)> = Vec::new();
// #wall3: class ctors with a rest param (`constructor(...args)`) need their
// standalone `_constructor` func_ptr registered in CLOSURE_REST_REGISTRY so
// standalone `_constructor` func_ptr registered as rest-bearing in the closure body registry so
// a member-new (`new ns.Sub(opts)` → js_new_function_construct →
// js_native_call_value) BUNDLES trailing args into the rest array. Without
// this the rest param binds to the first arg as a scalar (a=opts, not
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/async_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1602,7 +1602,7 @@ extern "C" fn async_resource_bind_trampoline(closure: *const ClosureHeader, rest

fn register_bind_trampoline_once() {
thread_local! {
// CLOSURE_REST_REGISTRY is thread-local, so each thread that
// The closure body registry is thread-local, so each thread that
// synthesizes a bind() trampoline must register the func_ptr once.
static REGISTERED: Cell<bool> = const { Cell::new(false) };
}
Expand Down
Loading
Loading