From 591d2ace5d73747016ae71a44b5835bed039e0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 12:26:53 +0000 Subject: [PATCH 1/2] perf(runtime): closure_is_arrow answers from the dispatch cache; ReadonlySet.has is a leaf; Set.clear exits on an empty set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closure_is_arrow probed the arrow registry — a thread-local hash lookup — on every receiver rebind, i.e. on every `this.handler(a, b)` call through a closure-typed field, while the dispatch strategy the call resolves right after already carries the same answer, memoised per body and kept coherent by js_register_closure_arrow_function's invalidation. Answer from the recent-bodies cache instead. js_readonly_set_has inlined its structural fallback (a handle scope and the generic method dispatch), so the genuine-Set arm — `componentTypeSet.has` on every command — paid the fallback's frame. The fallback is outlined and cold; the fast arm is a leaf. js_set_clear on an already-empty set has nothing to reset (the side-table mirrors the elements) and now returns before the table probe — half of a change set's per-entity `adds.clear(); removes.clear()`. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- crates/perry-runtime/src/closure/registry.rs | 8 +++- crates/perry-runtime/src/set.rs | 39 ++++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/closure/registry.rs b/crates/perry-runtime/src/closure/registry.rs index fb977d5340..e516034218 100644 --- a/crates/perry-runtime/src/closure/registry.rs +++ b/crates/perry-runtime/src/closure/registry.rs @@ -658,7 +658,13 @@ pub fn closure_is_arrow(closure: *const ClosureHeader) -> bool { if func_ptr.is_null() { return false; } - is_registered_arrow_function(func_ptr) + // The unified dispatch strategy already carries arrow-ness (the same + // registry answer, memoised per body and kept coherent by + // `js_register_closure_arrow_function`'s invalidation), and the call + // that follows a receiver rebind resolves it anyway — so answer from the + // recent-bodies cache instead of a second thread-local hash probe per + // call. (`this.handler(a, b)` on a closure-typed field paid both.) + resolve_strategy(func_ptr).is_arrow() } /// True if `closure` is a bound-method / bound-function value (its body is the diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 975f8ec6fb..f91f83dd1f 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -1194,10 +1194,18 @@ pub unsafe extern "C-unwind" fn js_readonly_set_has(receiver: f64, value: f64) - } } - // The structural fallback can allocate and re-enter generated code. Root - // both operands before crossing that boundary, then pass refreshed values - // into the existing dispatcher (which establishes its own roots before - // the first collecting probe). + readonly_set_has_structural(receiver, value) +} + +/// The structural fallback of [`js_readonly_set_has`]: it can allocate and +/// re-enter generated code, so it roots both operands before crossing that +/// boundary and passes refreshed values into the existing dispatcher (which +/// establishes its own roots before the first collecting probe). Out of line +/// so the genuine-`Set` arm above is a leaf: with the handle scope inlined, +/// every `componentTypeSet.has(type)` paid the fallback's full frame. +#[cold] +#[inline(never)] +unsafe fn readonly_set_has_structural(receiver: f64, value: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let receiver_handle = scope.root_nanbox_f64(receiver); let value_handle = scope.root_nanbox_f64(value); @@ -1452,6 +1460,12 @@ pub extern "C" fn js_set_clear(set: *mut SetHeader) { return; } unsafe { + // The side-table mirrors the elements exactly, so an already-empty + // set has nothing to reset — half of a change set's per-entity + // `adds.clear(); removes.clear()` — and skips the table probe. + if (*set).size == 0 { + return; + } (*set).size = 0; } SET_INDEX.with(|idx| { @@ -2137,6 +2151,23 @@ mod tests { let size = js_set_size(set); js_set_add(set, 3.0); assert_eq!(js_set_size(set), size); + // Clearing an empty set is a no-op that leaves it usable; clearing a + // populated one resets the side-table (the re-added value is found, + // the removed ones are not). + let empty = js_set_alloc(2); + js_set_clear(empty); + js_set_add(empty, 3.0); + assert_eq!(js_set_has(empty, 3.0), 1); + js_set_clear(set); + assert_eq!(js_set_size(set), 0); + assert_eq!(js_set_has(set, 1.0), 0); + js_set_add(set, 1.0); + assert_eq!(js_set_has(set, 1.0), 1); + js_set_clear(set); + // Restore the members the tail below expects (2 was deleted above). + for value in [1.0, 3.0, 4.0] { + js_set_add(set, value); + } // Growing past the scan bound hands every lookup to the side-table. for value in 100..120 { js_set_add(set, value as f64); From 9eb19e9969ed78b479bc69b47bef8269f763ebe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 12:27:51 +0000 Subject: [PATCH 2/2] changelog: fragment for #8952 Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- changelog.d/8952-closure-arrow-set-leaf.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8952-closure-arrow-set-leaf.md diff --git a/changelog.d/8952-closure-arrow-set-leaf.md b/changelog.d/8952-closure-arrow-set-leaf.md new file mode 100644 index 0000000000..b243ac9d04 --- /dev/null +++ b/changelog.d/8952-closure-arrow-set-leaf.md @@ -0,0 +1 @@ +- **runtime:** `closure_is_arrow` takes its answer from the memoised dispatch strategy instead of a second thread-local registry probe per receiver rebind; `ReadonlySet.has` outlines its structural fallback so the genuine-`Set` arm is a leaf; `Set.clear()` on an already-empty set returns before the side-table probe.