Skip to content

perf(runtime): positive-cache the box-pointer registry probe and inline the runtime-handle accessors - #7906

Merged
proggeramlug merged 2 commits into
mainfrom
perf/async-box-registry-cache
Aug 12, 2026
Merged

perf(runtime): positive-cache the box-pointer registry probe and inline the runtime-handle accessors#7906
proggeramlug merged 2 commits into
mainfrom
perf/async-box-registry-cache

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Two contained runtime changes on the hot path of async/await, found by profiling
gc-handoff/apps/asyncpipe.ts (currently 1.63× node) for the first time.

Why

The async-to-generator transform boxes every body local of an async function, and
js_box_get / js_box_set validate their operand against a thread-local hash set on
every access (perry#4898). On a promise-only kernel — 24 000 activations, 48 000 awaits,
no objects/strings/Map — the three registry probes are the largest single item in
Perry's async machinery
:

symbol share of leaf samples
is_registered_box_ptr 8.2 %
is_registered_i32_box_ptr 5.9 %
is_registered_bool_box_ptr 5.5 %
RuntimeHandle::get_nanbox_u64 6.4 %
RuntimeHandleScope::root_nanbox_f64 3.0 %
RuntimeHandle::get_raw_mut_ptr 2.1 %

The state machine re-reads the same handful of boxes (__gen_state, __gen_done,
__gen_executing, plus the activation's locals) on every step, so the probe is almost
always answering "yes" about an address it answered "yes" about a moment ago.

What

1. An 8-slot direct-mapped positive cache per box registry.

Sound because the registries are monotonic: js_box_alloc* inserts and nothing ever
removes — boxes are never freed — so "this address is a registered box" can never
become false, and an address can never be recycled into a non-box allocation. A cache hit
is therefore exactly as authoritative as the hash probe it replaces.

A negative cache would not be sound (an address that is not a box today can be minted
as one tomorrow), so only confirmed positives are recorded and every miss still falls
through to the hash set. The perry#4898 rejection — a read-only __TEXT.__cstring
address that passes every structural check — is unchanged.

test_clear_box_registry (test-only, and the one operation that breaks monotonicity)
clears the caches with the registries.

2. Make the RuntimeHandle accessors inlineable.

Their formatted expect / panic! arms were most of each function's estimated size and
kept them out of line in the release build, so a promise-heavy program paid a real call
frame per rooted-value read. The panics move to #[cold] #[inline(never)] helpers and the
accessors get #[inline]. No semantic change.

Tests

  • box_ptr_cache_rejects_a_colliding_unregistered_address — warms the cache with a real
    box, then probes plausible-but-unregistered addresses that map to the same cache
    slot. Sabotage-verified: changing the hit test from a full-address compare to a
    slot-occupancy compare makes it fail with
    a colliding unregistered address must still be rejected.
  • box_ptr_cache_eviction_does_not_lose_a_real_box — the cache is an accelerator, never
    the source of truth.
  • box_ptr_caches_do_not_cross_kinds — an ordinary box is not accepted as an i32/bool box.

Measurement

Numbers in the PR comments below and in gc-handoff/ASYNC2-NOTES.md. The dev box ran at
load 25–220 all session, so the A/B is by instructions retired (/usr/bin/time -l,
load-independent, run-to-run spread ≈0.2 %) with child CPU time alongside; authoritative
wall-clock numbers come from the quiet mini, which the main session owns.

What this PR is not

Profiling asyncpipe turned up two larger levers that are not in this PR, both
written up with measurements in gc-handoff/ASYNC2-NOTES.md:

  • ~14 % of asyncpipe is incremental-GC work in a program that runs zero GC
    cycles (PERRY_GC_MOVING_LOOP_POLLS=0 measures −14.1 %, still zero cycles). GC
    workstream.
  • ~9 % is the spec Get(resolution, "then") probe on every object an async function
    resolves with — re-interning the "then" key, a setjmp frame, and a full name-keyed
    miss walk into Object.prototype, 72 000 times, answering undefined every time. The
    safe fix needs an Object.prototype mutation-generation counter; getting that wrong
    makes a real thenable stop being assimilated, and the failure mode is a hang.

Summary by CodeRabbit

  • Performance

    • Improved runtime efficiency for boxed value lookups and handle access.
    • Reduced overhead in common runtime operations while preserving existing behavior.
  • Reliability

    • Maintained existing validation and error handling for invalid or expired runtime handles.
    • Improved consistency when working with different boxed value types.
  • Documentation

    • Added release documentation with profiling results and notes on future optimization opportunities.

…ne the runtime-handle accessors

The async-to-generator transform boxes every body local of an `async`
function, and `js_box_get`/`js_box_set` validate their operand against a
thread-local hash set on every access (perry#4898). On a promise-only kernel
(24 000 activations, 48 000 awaits) the three `is_registered_*_box_ptr`
probes were 8.2 % + 5.9 % + 5.5 % of leaf samples — the largest single item
in Perry's async machinery.

Front each registry with an 8-slot direct-mapped POSITIVE cache. Sound
because the registries are monotonic: boxes are never freed and nothing ever
removes an entry, so "this address is a registered box" cannot become false.
Only confirmed positives are recorded; a miss still falls through to the
authoritative hash set, so an unregistered-but-plausible address is rejected
exactly as strictly as before.

Also make the `RuntimeHandle` accessors and `RuntimeHandleScope` root helpers
inlineable: their formatted `expect`/`panic!` arms were most of each
function's estimated size and kept them out of line in the release build
(`get_nanbox_u64` 4.3 %, `root_nanbox_f64` 3.0 % of leaf samples on an
async-heavy program, purely as call frames). The panics move to `#[cold]`
out-of-line helpers.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 04adb794-0b44-4b76-8e17-55f30cffcd1c

📥 Commits

Reviewing files that changed from the base of the PR and between 2592791 and 940d9bd.

📒 Files selected for processing (3)
  • changelog.d/7906-async-box-registry-cache.md
  • crates/perry-runtime/src/box.rs
  • crates/perry-runtime/src/gc/roots/runtime_handles.rs

📝 Walkthrough

Walkthrough

The runtime adds positive caches for three box registries and improves runtime-handle inlining. Failure paths now use cold panic helpers. Tests cover cache collisions, eviction, kind isolation, and reset cleanup.

Changes

Runtime optimizations

Layer / File(s) Summary
Box registry caches
crates/perry-runtime/src/box.rs, changelog.d/7906-async-box-registry-cache.md
Three thread-local eight-slot caches record registered ordinary, i32, and boolean box pointers. Validation checks the matching cache before using the authoritative registry. Test cleanup clears caches, and tests cover collisions, eviction, and kind isolation.
Runtime-handle fast paths
crates/perry-runtime/src/gc/roots/runtime_handles.rs
Handle constructors, rooting methods, accessors, and pointer operations receive inlining annotations. Expired-handle and kind-mismatch failures use shared cold helpers.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • PerryTS/perry#7758: Modifies the same thread-local box registry state used by these caches.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/async-box-registry-cache

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Measurement

Two runtime archives from the same worktree (pre-/post-change), identical compiler source,
output basenames held constant in different directories. The dev box ran at load
25–220 all session, so the primary instrument is instructions retired
(/usr/bin/time -l, load-independent); observed run-to-run spread 0.15–0.28 %, so every
delta below is well clear of noise. Best of 9, arms interleaved.

program base instr fix instr Δ instr Δ cycles
gc-handoff/apps/asyncpipe.ts 1992.6 M 1944.9 M −2.40 % −4.28 %
pure async topology (24 000 activations, 48 000 awaits, numbers) 659.9 M 612.4 M −7.20 % −1.36 %
same, objects through the promises 1179.1 M 1127.0 M −4.42 % −5.34 %
Promise.all fan-out only (1200 × 200) 572.1 M 546.7 M −4.44 % −4.32 %

Child CPU time agreed in direction on the target (−4.8 %, best of 11, noise floor ~1 %).
Wall clock is not quoted — this host cannot resolve 5 % on a 0.13 s program, and the
authoritative numbers come from the quiet mini.

The gap between the target (−2.4 %) and the pure kernels (−4.4 to −7.2 %) is expected
dilution: the box probe is ~3.5 % of asyncpipe's leaf samples versus ~19.6 % of a
promise-only kernel.

No regression

19/19 corpus programs byte-identical to gc-handoff/m0810/expected/, exit 0.
Per-program instruction deltas (best of 5) all within ±0.7 %:

churn -0.16  churn_alloc -0.25  churn_read +0.02  push_num -0.57  push_cls -0.26
cycles +0.34  deeplist -0.02  tree -0.00  retain +0.16  retain1 +0.31
shapes +0.04  pipeline +0.24  interp -0.02  iso_miss -0.68   asyncpipe -2.37
  • Correctness canary: iso_miss prints checksum 437840 misses 0, including under
    PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 and
    PERRY_GC_VERIFY_EVACUATION=1 (both exit 0). asyncpipe likewise under both.
  • cargo test -p perry-runtime --lib (RUST_TEST_THREADS=1): 2157 passed, 0 failed.
  • Targeted oracle run over the 101 test-files/*{async,promise,await,generator,box,closure}*.ts
    the pinned node can execute: 99 byte-identical, 2 DIFF — both
    (test_gap_http_req_async_iterator, test_issue_4728_async_handler_res_end) are
    pre-existing on the clean baseline, and baseline vs fix are byte-identical to each
    other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant