Skip to content

perf(runtime): stop hoisting cold-path thread-locals into o[k]'s fast lane (−5.8%) - #10651

Open
proggeramlug wants to merge 2 commits into
mainfrom
perf/dynamic-key-remainder
Open

proggeramlug wants to merge 2 commits into
mainfrom
perf/dynamic-key-remainder

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Two thread-local resolutions sat unconditionally in js_object_get_field_by_name's prologue on an o[k] loop that never touches a Proxy or a .size key — both belonging to cold arms that are logically guarded and never taken.

544.7 → 512.9 instructions per access, −5.8%. The number is modest; the mechanism is the point, and one of the two fixes reaches far beyond this path.

Why a guard did not keep the TLS out

A thread_local! address resolution is readnone to LLVM — it has no observable side effect. So once a guarded cold arm is visible to the inliner, the optimizer is free to hoist just the address computation above the guard and run it unconditionally; it does not need the guard to be true to preserve behaviour. The Rust-level gate survives, the TLS call escapes it.

That is what put two adrp+add+ldr+blr sequences in the prologue:

  • the PROXIES registry lookup, reached via js_proxy_is_proxy behind is_proxy_id_band(raw_addr) — always false for an ordinary object;
  • RUNTIME_HANDLE_STACK's cold fallback, reached via RuntimeHandleScope::new() from the .size-key arm, whose own source comment already records losing this exact fight at the Rust level.

Fixing the Proxy block alone removed only one of them; a rebuild and second disassembly found the other still there by a different route. Both arms are now #[cold] #[inline(never)], which keeps them opaque to the inliner.

The runtime_handles half is the general fix. RuntimeHandleScope::new() is called from dozens of arms across the runtime, many of them small and gated behind a cheap guard inside an otherwise hot function. Splitting that fallback protects all of them, not just this path, and it lets the fast published arm stay #[inline(always)] without dragging the raw TLS call to every call site.

A third, smaller change: try_data_get_bytes called is_plausible_heap_addr(addr) explicitly and then again inside try_read_gc_header, and LLVM could not CSE the two across classify_heap_generation's intervening cache write. The one call site that has already proved the predicate now uses try_read_gc_header_known_plausible.

Found by disassembly, not by reading

The profile charged both _tlv_get_addr calls directly to js_object_get_field_by_name rather than to any callee, which is what said "inlined into the prologue" rather than "called from somewhere". otool -tV on the real function plus nm to resolve the branch targets identified which two thread-locals they were. After rebuilding, the same disassembly confirms both sequences are gone and the only remaining blrs are ordinary vtable and closure dispatch.

Measurement

arm (dyn80−dyn16)/64 control (loop80−loop16)/64
base (0058babd83) 544.7 0.00
after 512.9 −0.10

Differenced within each binary so fixed per-process cost and code layout cancel before the arms are compared. N=20000, median of 7.

Three hypotheses that came back negative

Stated because they are worth not re-running:

  • try_data_get_bytes's from_utf8 and Bloom-hash preamble is spec-required work, not re-derivation.
  • is_anon_shape_class_id is still 11.2% of the loop after the lock-free mirror added in perf(runtime): stop re-deriving the receiver on every dynamic-key read (−19%) #10570, and that residue is the per-image current() lookup — hash plus an 8-slot probe. It is already the cheapest form available given that a process-global mirror would answer one image's question from another image's registrations.
  • keys_find_slot_by_bytes and its memcmp are genuine key-byte comparison. A hand-rolled short-key compare was judged not worth the correctness risk for the expected win.

Validation

  • test_gap_dynamic_key_proxy_receiver.ts (new): trapped, pass-through and nested Proxies, an array accessed through a Proxy, and loops interleaving plain and Proxy receivers — the arm that was made cold must still be correct when it is taken. Byte-identical to node 26.5.1.
  • test_gap_dynamic_key_read_paths.ts from perf(runtime): stop re-deriving the receiver on every dynamic-key read (−19%) #10570 still byte-identical.
  • GC stress, seeds 1 and 42 with from-space protection, evacuation verification and PERRY_GC_FROMSPACE_SCAN_ABORT=1: four runs, all exit 0 and node-matching, dangling=0, missing_rewrites=0, copying minors non-zero throughout (32, 29, 241, 247).
  • Full gap suite: 831 of 838 pass; the 6 failures are pre-existing. The one flagged as a regression, test_gap_9592_child_timeout_threads, is a host artifact — /bin/true does not exist on this machine, so node itself throws ENOENT regardless of Perry, and base and arm produce byte-identical Perry output.
  • cargo test -p perry-runtime --lib, RUST_TEST_THREADS=1: 4,016 passed, 2 failed — both confirmed pre-existing by reverting to pristine origin/main via a patch round-trip (not git stash, which is shared across worktrees here) and reproducing the identical debug_assert! panics.
  • cargo fmt --check, file-size cap, test registration, check_thread_locals.py, and RUSTFLAGS=-D warnings cargo check --all-targets all clean.

Summary by CodeRabbit

  • Performance

    • Improved performance for dynamic object-key reads, reducing the instruction cost per access in common cases.
    • Optimized property reads through prototype chains without changing lookup behavior.
  • Bug Fixes

    • Preserved correct behavior for Proxy receivers, nested Proxies, missing properties, ordinary objects, and numeric-string array indexes.
  • Tests

    • Added coverage for dynamic-key reads across Proxy and ordinary-object scenarios.

… lane

js_object_get_field_by_name's Proxy-receiver block and RuntimeHandleScope's
raw-thread_local! fallback were both small enough to inline into the hot
dynamic-key-read path. A thread_local! address resolution is readnone from
LLVM's point of view, so once inlined, the optimizer hoisted the proxy
registry's and the transient-handle root stack's TLS lookups out of their
guards (is_proxy_id_band, a "size"-key check) and ran them unconditionally
on every js_object_get_field_by_name call, Proxy or not. Splitting each
into its own #[inline(never)] function keeps the optimizer from seeing
inside at the call site, so nothing gets hoisted past the guard. Also skip
try_read_gc_header's redundant is_plausible_heap_addr recheck in
try_data_get_bytes's prototype-chain loop, where the caller already proved
it true one statement above.

Measured on a two-property-object o[k] loop (never touching a Proxy or a
.size key): 544.9 -> 513.0 instructions/access (-5.9%), via
(dyn80-dyn16)/64 differenced against 2x the iteration count to cancel
per-process fixed overhead. The (loop80-loop16)/64 no-op control reads
~0 in both arms (base: -0.01..-0.04, mine: -0.02..0.13), confirming the
technique resolves changes this small. Verified via disassembly that both
_tlv_get_addr calls are gone from the function's prologue.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 9157634a-75d9-43ce-9fb0-d9a45c85fae2

📥 Commits

Reviewing files that changed from the base of the PR and between 68a5454 and 1bbb70a.

📒 Files selected for processing (6)
  • changelog.d/10651-dynamic-key-cold-tls.md
  • crates/perry-runtime/src/gc/roots/runtime_handles.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/native_get.rs
  • crates/perry-runtime/src/value/addr_class.rs
  • test-files/test_gap_dynamic_key_proxy_receiver.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The change isolates cold Proxy and runtime-handle TLS paths, adds a GC-header helper for already-validated addresses, and adds dynamic-key Proxy coverage.

Changes

Runtime hot-path optimization

Layer / File(s) Summary
GC header plausibility path
crates/perry-runtime/src/value/addr_class.rs, crates/perry-runtime/src/object/native_get.rs
Adds try_read_gc_header_known_plausible and uses it after an existing heap-address plausibility check.
Proxy receiver cold dispatch
crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs, test-files/test_gap_dynamic_key_proxy_receiver.ts
Moves Proxy receiver forwarding into a cold, non-inlined helper. Adds dynamic-key tests for traps, passthrough reads, nested Proxies, missing properties, loops, and numeric-string array indexes.
Runtime handle cold fallback
crates/perry-runtime/src/gc/roots/runtime_handles.rs, changelog.d/10651-dynamic-key-cold-tls.md
Moves the raw TLS fallback into a cold, non-inlined function. The changelog records performance measurements and validation results.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Refactor

Merge Risk: ⚪ Minimal · up to 1bbb7

No actionable correctness, stability, security, or repository-contract issue remains from the reviewed change.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main runtime performance change: preventing cold-path thread-local work from entering the dynamic-key access fast path. It is specific and concise.
Description check ✅ Passed The description provides a detailed summary, explains the implementation, reports measurements, and documents extensive validation. It does not reproduce every template heading, such as Related issue …
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 5 files. (1 skipped: 1 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

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