perf(runtime): cache the hot thread-locals so one allocation pays one _tlv_get_addr - #7474
Conversation
…_get_addr
On Darwin every `thread_local!` access is an out-of-line `_tlv_get_addr` call,
and two different thread-locals are two different descriptors — so N distinct
thread-locals on one path cost N calls no matter how well it inlines. A single
`{v, w}` object literal touched about a dozen of them, and `_tlv_get_addr` was
27.9% of self time on `gc-handoff/bench/churn.ts` with the collector idle.
`tls_hot::HotTls` caches those addresses in one const-init thread-local. The
storage does not move — every slot is the address of the existing
`thread_local!` in its owning module, so init order, lazy init and destructor
registration are unchanged. Slots are untyped so owning modules keep their
storage private; `tls_hot::tests::cached_addresses_match_thread_locals` asserts
every address/accessor pairing, which is what stands between a mis-wired
`fill()` and a well-typed reference to the wrong object.
Four things fell out of profiling the remainder:
- `incremental_mark_barrier_value` read a thread-local on every heap-pointer
store to prove a null pointer was still null. It now consults the existing
process-global `PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT` first, the same
authority generated code already trusts. `incremental_mark_barrier_enable`
now arms that count *before* installing the thread-local pointer, so there is
no window where the pointer is live and the count still reads idle — that
ordering was merely tidy before and is load-bearing now.
- `js_array_length` probed the Map and Set registries on every call. Monotone
"has anything ever been registered" flags answer for programs that use
neither, with no counter to get wrong.
- The page-generation cache had **one** entry, while the write barrier
classifies at least two unrelated addresses per store; they evicted each
other and it missed on essentially every call. Now 4-way, behind an
`UnsafeCell` — `Cell::get` copies the whole set, which cost more than the
lookup it avoids.
- `layout_forget_object` takes one `borrow_mut` per map instead of a `borrow`
to test emptiness plus a second `borrow_mut` to remove.
Measured on `gc-handoff/bench`, interleaved A/B, best-of-5 user CPU on a quiet
host: churn 1.16x, cycles 1.10x, retain1 1.08x, retain 1.07x, deeplist 1.07x,
tree 1.03x. Peak RSS flat (worst cell +0.9%). Program output byte-identical on
all six.
The collector is untouched: `gc_ratchet` measurements against clean `main`
agree on all 108 compared metrics except `heap_used_bytes` on 2 of 12 probes
(<=0.8%, and it moves on different probes per build — allocation-boundary
jitter, not behaviour). Per-cycle `PERRY_GC_TRACE` on churn/tree/retain is
unchanged, including tree copying volume at 0.017 GB / 0.2 M object-copies.
Refs #7469.
📝 WalkthroughWalkthroughThe runtime adds a per-thread hot-TLS address cache and routes allocation, page metadata, GC state, temporary roots, and registry checks through cached accessors. Barrier activation ordering and page-generation caching also change. ChangesHot TLS runtime paths
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Runtime
participant HotTls
participant ArenaAllocator
participant GCBarrier
Runtime->>HotTls: resolve and cache thread-local addresses
ArenaAllocator->>HotTls: read cached arena state
HotTls-->>ArenaAllocator: return typed arena accessors
GCBarrier->>HotTls: read cached barrier state
HotTls-->>GCBarrier: return typed barrier accessors
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-runtime/src/arena/page_meta.rs`:
- Around line 551-552: Update the cache-miss documentation above
classify_heap_generation to describe re-priming the four-way
PageGenerationCacheSet via classify_heap_generation_uncached, replacing the
incorrect “one-entry cache” wording.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 48d90df3-6236-4996-bc1b-28ecb92150e9
📒 Files selected for processing (15)
changelog.d/7474-hot-tls-alloc-path.mdcrates/perry-runtime/src/arena/allocators.rscrates/perry-runtime/src/arena/block.rscrates/perry-runtime/src/arena/mod.rscrates/perry-runtime/src/arena/page_meta.rscrates/perry-runtime/src/gc/barrier.rscrates/perry-runtime/src/gc/hot_tls.rscrates/perry-runtime/src/gc/layout.rscrates/perry-runtime/src/gc/mod.rscrates/perry-runtime/src/gc/roots.rscrates/perry-runtime/src/gc/roots/temp_roots.rscrates/perry-runtime/src/lib.rscrates/perry-runtime/src/map.rscrates/perry-runtime/src/set.rscrates/perry-runtime/src/tls_hot.rs
| /// Cache-miss arm of [`classify_heap_generation`]: consult the page map and | ||
| /// re-prime the one-entry cache. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the cache description.
Line 552 says “one-entry cache”. classify_heap_generation_uncached now inserts into the four-way PageGenerationCacheSet.
Proposed fix
-/// re-prime the one-entry cache.
+/// re-prime the multi-way cache.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// Cache-miss arm of [`classify_heap_generation`]: consult the page map and | |
| /// re-prime the one-entry cache. | |
| /// Cache-miss arm of [`classify_heap_generation`]: consult the page map and | |
| /// re-prime the multi-way cache. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-runtime/src/arena/page_meta.rs` around lines 551 - 552, Update
the cache-miss documentation above classify_heap_generation to describe
re-priming the four-way PageGenerationCacheSet via
classify_heap_generation_uncached, replacing the incorrect “one-entry cache”
wording.
|
Reviewed and merged. This got the deepest look of today's batch because it touches the write-barrier classification path, where a stale answer is a lost object. What I verified independently on a rebased build:
The honest "acceptance criterion 1 not met — 27.9% → 23.5%" framing is appreciated and correct to leave #7469 open. Worth noting for the remainder: the measured wins (1.03–1.16×) line up with the temp-root/TLS share I saw on |
…ls per temporary become a store and a load The #6951 temp-root contract cost three runtime calls per protected temporary — js_gc_temp_root_push, a mandatory re-read, truncate — and after #7474 those three were 206 of the 542 remaining _tlv_get_addr-attributed samples on churn.ts. But named locals already demonstrate the cheap form of the same root: an entry alloca bound to a shadow-frame slot, written and re-read with plain stores and loads, upgraded by the RS4GC/stack-map lowering into a relocated addrspace(1) slot. A temp needs nothing a local does not. TempRootPool (per-function, compile-time-only) lowers the same API onto that mechanism: push = store + the identical bind/root-shading emission a named local's store uses (emit_shadow_slot_bind_ptr, extracted from emit_shadow_slot_bind_for_local); get = load; set = store+bind; truncate = zero the slot, clear the frame mirror, release the pool entries at and above it — the same drop-everything-above contract the FFI stack imposed. Handles keep the String currency, so every caller (RootedOperands, StoreOperandGuard, rooting.rs's call_rooted) compiles unchanged. The pool reuses slots by stack watermark; frame slots are reserved on demand via reserve_shadow_slot, whose in-place slot-count rewrite is what rules out the #7184 out-of-frame-bounds shape. The store-then-bind order at the push site is the #7192 dominance invariant, stated at the emission. When shadow-stack emission is off (reserve_shadow_slot -> None) the whole function falls back to the FFI stack byte-for-byte; ShadowSavepoint's temp-depth restore keeps working on both arms (the FFI stack simply stays empty in alloca mode). js_array_push_f64_temp_rooted remains for the fallback arm only — in alloca mode the fused form is load + js_array_push_f64 + store, which is cheaper than the call it was fusing. Refs #7469.
… calls per temporary become a store and a load (#7487) * perf(codegen): lower temp roots onto pooled frame allocas — 3 FFI calls per temporary become a store and a load The #6951 temp-root contract cost three runtime calls per protected temporary — js_gc_temp_root_push, a mandatory re-read, truncate — and after #7474 those three were 206 of the 542 remaining _tlv_get_addr-attributed samples on churn.ts. But named locals already demonstrate the cheap form of the same root: an entry alloca bound to a shadow-frame slot, written and re-read with plain stores and loads, upgraded by the RS4GC/stack-map lowering into a relocated addrspace(1) slot. A temp needs nothing a local does not. TempRootPool (per-function, compile-time-only) lowers the same API onto that mechanism: push = store + the identical bind/root-shading emission a named local's store uses (emit_shadow_slot_bind_ptr, extracted from emit_shadow_slot_bind_for_local); get = load; set = store+bind; truncate = zero the slot, clear the frame mirror, release the pool entries at and above it — the same drop-everything-above contract the FFI stack imposed. Handles keep the String currency, so every caller (RootedOperands, StoreOperandGuard, rooting.rs's call_rooted) compiles unchanged. The pool reuses slots by stack watermark; frame slots are reserved on demand via reserve_shadow_slot, whose in-place slot-count rewrite is what rules out the #7184 out-of-frame-bounds shape. The store-then-bind order at the push site is the #7192 dominance invariant, stated at the emission. When shadow-stack emission is off (reserve_shadow_slot -> None) the whole function falls back to the FFI stack byte-for-byte; ShadowSavepoint's temp-depth restore keeps working on both arms (the FFI stack simply stays empty in alloca mode). js_array_push_f64_temp_rooted remains for the fallback arm only — in alloca mode the fused form is load + js_array_push_f64 + store, which is cheaper than the call it was fusing. Refs #7469. * docs(changelog): fragment for temp-root frame allocas (#7487) * chore: bump version to 0.5.1284 --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
… aarch64 #7474 cached the addresses of the thread-locals on the allocation path, but `HOT` is itself a `thread_local!`, so every runtime function reading any hot field still paid one `_tlv_get_addr` call. Symbolicated on the pinned quiet host at 9938cbc that residue is 27.0% of `churn_alloc` self time, and the call-graph attribution is concentrated rather than diffuse: seven functions carry 98% of it and every one of them resolves `HOT`. Publish the cache's address into one `pthread_key_create` slot and read it back through `TPIDRRO_EL0`, which is how `pthread_getspecific` itself is implemented and what mimalloc (already linked here) does on this platform. The resolution becomes `mrs` plus two loads that LLVM can CSE across a function instead of an out-of-line call that clobbers caller-saved registers. The publishing thread reads its slot back through the direct path and compares it against what `pthread_setspecific` was handed; a mismatch disables the direct path process-wide and every thread falls back to `_tlv_get_addr`. There is no path on which a wrong address reaches the allocator. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
…tructural half) (#7565) * perf(runtime): reach the hot-TLS cache without _tlv_get_addr on Apple aarch64 #7474 cached the addresses of the thread-locals on the allocation path, but `HOT` is itself a `thread_local!`, so every runtime function reading any hot field still paid one `_tlv_get_addr` call. Symbolicated on the pinned quiet host at 9938cbc that residue is 27.0% of `churn_alloc` self time, and the call-graph attribution is concentrated rather than diffuse: seven functions carry 98% of it and every one of them resolves `HOT`. Publish the cache's address into one `pthread_key_create` slot and read it back through `TPIDRRO_EL0`, which is how `pthread_getspecific` itself is implemented and what mimalloc (already linked here) does on this platform. The resolution becomes `mrs` plus two loads that LLVM can CSE across a function instead of an out-of-line call that clobbers caller-saved registers. The publishing thread reads its slot back through the direct path and compares it against what `pthread_setspecific` was handed; a mismatch disables the direct path process-wide and every thread falls back to `_tlv_get_addr`. There is no path on which a wrong address reaches the allocator. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * perf(runtime): cache the learned-inline-fields table address too With `HOT` reachable without `_tlv_get_addr`, the residual on `churn_alloc` was 3.5% of self time and 100% of it attributed to one caller, `js_object_alloc_class_inline_keys` — which is `learned_inline_field_count`, run on every dynamic construct to right-size the inline slot count. The other thread-locals that function names statically (`MARK_SEEDS`, `WRITE_BARRIER_TRACE_COUNTERS`) sit behind cold gates and never resolve. Route it through the existing address cache, following the four-step contract in `tls_hot`: slot, provider next to the `thread_local!`, wiring in `fill`, and the pairing assertion that stands between a mis-wire and a well-typed reference to the wrong object. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * docs(changelog): #7469 structural half — direct TSD hot-TLS resolution (#7565) Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * docs(engine-plan): _tlv_get_addr measured out at 1.1% (#7565) Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * docs(changelog): record the same-session main-arm ratchet A/B (#7565) Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * fix(runtime): the TPIDRRO_EL0 read must not be `pure` The first version of this change marked the thread-pointer asm `options(pure, nomem)` so LLVM could CSE it across a function. `pure` promises the result depends only on the inputs, and this asm has none, so LLVM may compute it once and reuse the value anywhere in the function — including across a point where execution resumes on a *different* thread. `perry-stdlib`'s async bridge is exactly that shape: `hot()` is `#[inline(always)]` and LTO inlines it into futures tokio polls, so a hoisted thread pointer outlives the thread it was read on. Every `node:net` / `node:http` server aborted with tokio's "there is no reactor running", 5/5, against 5/5 clean on `main`. No unit test and no allocation benchmark reproduced it — it took the gap suite. The counter-argument that `@llvm.threadlocal.address` already has this freedom does not hold: on Darwin it lowers to a call through the TLV descriptor, which LLVM will not hoist across arbitrary code. Replacing the call with inline asm is what made the hoist possible. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * docs(changelog): record the `pure` incident and the gap-suite triage (#7565) Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH * chore: bump version --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…oops The outlined per-`new`-site allocator has been the default since [#bloat]: it collapses ~145 lines of per-class-constant IR per site into one js_object_alloc_class_inline_keys call. The size half of that decision still holds — measured ~268 bytes of machine code per site, +214,656 over an 800-site program. The SPEED half has inverted. The comment reads '~17% faster on an 8M-allocation loop'; today the outlined form is 1.81x SLOWER on churn_alloc and 1.78x on push_cls. Nothing about the inline bump changed — everything around the allocation got cheaper (#7474 #7486 #7487 #7501 #7525 #7532 #7535 #7536 #7552), so the surviving FFI call and the thread-local resolutions it performs now dominate what its code bloat costs. Those resolutions cannot be made cheaper on Darwin: Mach-O has no local-exec TLS model, and building the runtime with -Ztls-model=local-exec leaves the blr through the TLV descriptor byte-identical (measured 1.02x). Only their count can be reduced. So the choice becomes per site rather than global. A `new` inside a loop takes the inline bump; everything else keeps the outlined call and adds nothing to binary size. Loop membership reuses the existing loop_targets stack — switch frames push an empty continue label, every loop pushes a real one, the same discriminator Stmt::Continue already relies on. Measured: churn_alloc 1.81x, push_cls 1.81x, churn 1.56x — the full unconditional-inline ceiling. Size +0 bytes for 800 sites none of which are in loops; equal to all-inline when every site is. tree is -1.4%.
Workstream A of #7469, items 1 and 2: cut the number of thread-local resolutions
on the allocation path. Workstream B (per-object footprint) is not in this PR.
The mechanism
On Darwin every
thread_local!access is an out-of-line_tlv_get_addrcall inlibdyld. Unlike ELF'slocal-exec/initial-execit is not inlined and notcached across accesses — and, critically, LLVM can CSE repeated accesses to the
same thread-local but two different thread-locals are two different
descriptors. So N distinct thread-locals on one path cost N calls however well
that path inlines. A single
{v, w}object literal touched about a dozen.crates/perry-runtime/src/tls_hot.rscaches those addresses in oneconst-initialised thread-local. The storage does not move: every slot is theaddress of the existing
thread_local!in its owning module, so init order,lazy init and destructor registration are all unchanged. Slots are untyped
*mut u8so each owning module keeps its storage type private — which means amis-wired
fill()would hand out a well-typed reference to the wrong object,so
tls_hot::tests::cached_addresses_match_thread_localsasserts everyaddress/accessor pairing, and
every_slot_is_populatedassertsfill()skippednothing.
What profiling the remainder turned up
Symbolicated
sampleoverchurn.ts, attributing_tlv_get_addrto itsimmediate caller, found four things worth fixing beyond the consolidation:
incremental_mark_barrier_valueread a thread-local on every heap-pointerstore — 91 of 653 attributed samples, all spent proving a null pointer was
still null. It now consults the existing process-global
PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNTfirst, which is the sameauthority generated code already trusts for exactly this question.
incremental_mark_barrier_enablenow arms that count before installingthe thread-local pointer (and
disablestill clears the pointer beforedecrementing), so there is no window where the pointer is live while the count
reads idle. That ordering was merely tidy before; this PR makes it
load-bearing, so it is written down at the site.
js_array_lengthprobed the Map and Set registries on every call — anarr.lengthloop condition paying two thread-local hash lookups. Monotone"has anything ever been registered" flags answer for programs that use
neither. Monotone deliberately: a maintained count is a count that can be got
wrong, and this one only ever needs to prove nothing exists.
classifies at least two unrelated addresses per store (child, then parent) —
they evicted each other and it missed on essentially every call, 71 self
samples in the map lookup the cache exists to avoid. Now 4-way. It lives
behind an
UnsafeCellrather than aCellbecauseCell::getreturns acopy, and copying the set on every classification cost more than the lookup
(caught as a 2%
retain.tsregression while bisecting my own arms).layout_forget_objecttakes oneborrow_mutper map instead of aborrowto test emptiness plus a secondborrow_mutto remove.gc/hot_tls.rsexists becausebarrier.rscrossed the 2000-line cap; theaccessor/provider pairs sit together there so the casts are reviewable side by
side.
Results
gc-handoff/bench, interleaved A/B (arms alternate round by round so load drifthits both equally), best-of-5 user CPU, quiet host,
PERRY_NO_AUTO_OPTIMIZE=1:Program output is byte-identical on all six.
_tlv_get_addr27.9% -> 23.5% ofleaf samples; attributed callers 653 -> 542.
Where the acceptance criteria stand
Being explicit, because this does not close the ticket:
_tlv_get_addr< 5%cargo testgreenperry-runtime(1715 tests)Why 1 and 2 are not reachable by consolidation alone. The floor of this
design is one resolution per runtime FFI call, and generated code makes ~12 of
them per object literal:
js_gc_temp_root_push/_get/_truncateare alone206 of the 542 remaining samples, three separate calls around a single
allocating expression. Driving this below 5% needs the call count to drop, not
the per-call cost — i.e. workstream A item 4 (codegen emits the bump inline),
which the ticket already anticipates as "likely its own ticket once 1 lands".
The other large remaining item on this profile is not TLS at all:
js_typed_feedback_class_field_set_guard+class_field_fast_contractare ~12%of self time.
Validation
cargo test --release -p perry-runtime: 1715 pass.gc_ratchetvs cleanmain: the pinned artifact currently fails onmainitself (28 regressions, identical set on both arms — it predates gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) #7432/gc: old-generation hole free list — swept holes become reusable capacity (#7437) #7443/
arena: recycled-block pool — block round-trips through the allocator were the tree.ts RSS term (#7438) #7449), so I measured clean
mainwith the same harness on the same host anddiffed. All 108 compared metrics agree except
heap_used_byteson 2 of 12probes, <=0.8% — and it moves on different probes per build, so it is
allocation-boundary jitter, not behaviour. Every metric in the
gcfamily(
minor_cycles,step_cycles,copied_objects,copied_bytes,promoted_objects,promoted_bytes,freed_bytes) is identical on all 12,and correctness passes everywhere.
PERRY_GC_TRACE: churn 105 cycles / 0.03 s total pause / 2.x msmax, tree 43, retain 11 — unchanged. tree copying volume stays at the
post-gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) #7432 level, 0.017 GB / 0.2 M object-copies.
cargo fmt --all -- --check,scripts/check_file_size.shclean.One thing to flag. A single run of the suite showed
gc::tests::root_words::bare_address_in_shadow_slot_survives_a_real_collectionfailing (a budgeted cycle stopping early), under load average 17 with another
build saturating the machine. It has not recurred in 33 subsequent runs on this
branch, and clean
mainis also 33/33 clean, so I could not attribute it eitherway. Recording it rather than dropping it.
Refs #7469.
Summary by CodeRabbit
Performance
Reliability
Documentation