perf(runtime): stop hoisting cold-path thread-locals into o[k]'s fast lane (−5.8%) - #10651
proggeramlug wants to merge 2 commits into
Conversation
… 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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 8 included reviews per hour; 1 remains after this review. 📝 WalkthroughWalkthroughThe change isolates cold Proxy and runtime-handle TLS paths, adds a GC-header helper for already-validated addresses, and adds dynamic-key Proxy coverage. ChangesRuntime hot-path optimization
Priority: ⬇️ Low Estimated code review effort: 2 (Simple) | ~10 minutes Change: Refactor Merge Risk: ⚪ Minimal · up to No actionable correctness, stability, security, or repository-contract issue remains from the reviewed change. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Pushed the cfg-gated fix and read CI's own cargo-test job on the real failing runner: green (run 35433215970) where the unconditional perry_thread_local! swap was red (run 35374727647), same commit otherwise. That settles causation directly, superseding the local repro attempts (macOS both arms, qemu Linux) which all came back clean and were inconclusive on their own -- including a qemu-VM A/B whose two SIGKILLs were momentarily misread as a reproduced crash before being traced to an operator pkill -f self-match, not a fault. Also resolves why cargo-test stayed green on #10644/#10647/#10650/ #10651 against the same base: none of them touch shadow_stack.rs, and this PR was never merged to main, so their runs never contained the change at all. The internal mechanism inside tls_hot.rs's resolution path is still not understood; #10709 tracks that open half. This commit only updates the code comment and changelog to say plainly what is now confirmed versus what remains unknown.
|
Landed in merge train 222 (#10732), released as v0.5.1601 — main is now Closing rather than merging is how trains work here: the eight PRs were cherry-picked onto one tree, validated together, and landed under the train's own commit, so GitHub cannot mark this one merged even though your change is on main. Close-keywords in a source PR body never fire under this scheme, so the issues this train resolved were closed from the train's body instead. The tree passed: all nine cheap gates, |
Pushed the cfg-gated fix and read CI's own cargo-test job on the real failing runner: green (run 35433215970) where the unconditional perry_thread_local! swap was red (run 35374727647), same commit otherwise. That settles causation directly, superseding the local repro attempts (macOS both arms, qemu Linux) which all came back clean and were inconclusive on their own -- including a qemu-VM A/B whose two SIGKILLs were momentarily misread as a reproduced crash before being traced to an operator pkill -f self-match, not a fault. Also resolves why cargo-test stayed green on #10644/#10647/#10650/ #10651 against the same base: none of them touch shadow_stack.rs, and this PR was never merged to main, so their runs never contained the change at all. The internal mechanism inside tls_hot.rs's resolution path is still not understood; #10709 tracks that open half. This commit only updates the code comment and changelog to say plainly what is now confirmed versus what remains unknown.
Pushed the cfg-gated fix and read CI's own cargo-test job on the real failing runner: green (run 35433215970) where the unconditional perry_thread_local! swap was red (run 35374727647), same commit otherwise. That settles causation directly, superseding the local repro attempts (macOS both arms, qemu Linux) which all came back clean and were inconclusive on their own -- including a qemu-VM A/B whose two SIGKILLs were momentarily misread as a reproduced crash before being traced to an operator pkill -f self-match, not a fault. Also resolves why cargo-test stayed green on #10644/#10647/#10650/ #10651 against the same base: none of them touch shadow_stack.rs, and this PR was never merged to main, so their runs never contained the change at all. The internal mechanism inside tls_hot.rs's resolution path is still not understood; #10709 tracks that open half. This commit only updates the code comment and changelog to say plainly what is now confirmed versus what remains unknown.
Two thread-local resolutions sat unconditionally in
js_object_get_field_by_name's prologue on ano[k]loop that never touches a Proxy or a.sizekey — 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 isreadnoneto 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+blrsequences in the prologue:PROXIESregistry lookup, reached viajs_proxy_is_proxybehindis_proxy_id_band(raw_addr)— always false for an ordinary object;RUNTIME_HANDLE_STACK's cold fallback, reached viaRuntimeHandleScope::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_handleshalf 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_bytescalledis_plausible_heap_addr(addr)explicitly and then again insidetry_read_gc_header, and LLVM could not CSE the two acrossclassify_heap_generation's intervening cache write. The one call site that has already proved the predicate now usestry_read_gc_header_known_plausible.Found by disassembly, not by reading
The profile charged both
_tlv_get_addrcalls directly tojs_object_get_field_by_namerather than to any callee, which is what said "inlined into the prologue" rather than "called from somewhere".otool -tVon the real function plusnmto resolve the branch targets identified which two thread-locals they were. After rebuilding, the same disassembly confirms both sequences are gone and the only remainingblrs are ordinary vtable and closure dispatch.Measurement
(dyn80−dyn16)/64(loop80−loop16)/640058babd83)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'sfrom_utf8and Bloom-hash preamble is spec-required work, not re-derivation.is_anon_shape_class_idis 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-imagecurrent()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_bytesand itsmemcmpare 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.tsfrom perf(runtime): stop re-deriving the receiver on every dynamic-key read (−19%) #10570 still byte-identical.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).test_gap_9592_child_timeout_threads, is a host artifact —/bin/truedoes not exist on this machine, so node itself throwsENOENTregardless 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 pristineorigin/mainvia a patch round-trip (notgit stash, which is shared across worktrees here) and reproducing the identicaldebug_assert!panics.cargo fmt --check, file-size cap, test registration,check_thread_locals.py, andRUSTFLAGS=-D warnings cargo check --all-targetsall clean.Summary by CodeRabbit
Performance
Bug Fixes
Tests