Skip to content

perf(runtime): cache the hot thread-locals so one allocation pays one _tlv_get_addr - #7474

Merged
proggeramlug merged 2 commits into
mainfrom
perf/7469-tls-alloc-path
Aug 6, 2026
Merged

perf(runtime): cache the hot thread-locals so one allocation pays one _tlv_get_addr#7474
proggeramlug merged 2 commits into
mainfrom
perf/7469-tls-alloc-path

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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_addr call in
libdyld. Unlike ELF's local-exec / initial-exec it is not inlined and not
cached 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.rs caches those addresses in one
const-initialised 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 all unchanged. Slots are untyped
*mut u8 so each owning module keeps its storage type private — which means a
mis-wired fill() would hand out a well-typed reference to the wrong object,
so tls_hot::tests::cached_addresses_match_thread_locals asserts every
address/accessor pairing, and every_slot_is_populated asserts fill() skipped
nothing.

What profiling the remainder turned up

Symbolicated sample over churn.ts, attributing _tlv_get_addr to its
immediate caller, found four things worth fixing beyond the consolidation:

  • incremental_mark_barrier_value read a thread-local on every heap-pointer
    store
    — 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_COUNT first, which is the same
    authority generated code already trusts for exactly this question.
    incremental_mark_barrier_enable now arms that count before installing
    the thread-local pointer (and disable still clears the pointer before
    decrementing), 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_length probed the Map and Set registries on every call — an
    arr.length loop 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.
  • The page-generation cache had one entry, while the write barrier
    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 UnsafeCell rather than a Cell because Cell::get returns a
    copy, and copying the set on every classification cost more than the lookup
    (caught as a 2% retain.ts regression while bisecting my own arms).
  • layout_forget_object takes one borrow_mut per map instead of a
    borrow to test emptiness plus a second borrow_mut to remove.

gc/hot_tls.rs exists because barrier.rs crossed the 2000-line cap; the
accessor/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 drift
hits both equally), best-of-5 user CPU, quiet host, PERRY_NO_AUTO_OPTIMIZE=1:

bench main this PR speedup RSS main RSS PR
churn 4.32 s 3.71 s 1.16x 24.5 MB 24.6 MB
cycles 1.09 s 0.99 s 1.10x 29.5 MB 29.7 MB
retain1 1.15 s 1.06 s 1.08x 167.4 MB 168.9 MB
retain 4.49 s 4.18 s 1.07x 422.5 MB 425.3 MB
deeplist 1.76 s 1.65 s 1.07x 163.8 MB 162.9 MB
tree 10.12 s 9.85 s 1.03x 193.0 MB 192.8 MB

Program output is byte-identical on all six. _tlv_get_addr 27.9% -> 23.5% of
leaf samples; attributed callers 653 -> 542.

Where the acceptance criteria stand

Being explicit, because this does not close the ticket:

criterion status
1. _tlv_get_addr < 5% not met — 27.9% -> 23.5%. See below.
2. churn >= 2x not met — 1.16x.
3. tree/retain improve, no RSS regression met — 1.03x / 1.07x, RSS within 0.9%
4. no GC regressions met — see below
5. ratchet probes hold met — see below
6. workstream B, bytes/record not attempted (separate workstream)
7. cargo test green met for perry-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 / _truncate are alone
206 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_contract are ~12%
of self time.

Validation

One thing to flag. A single run of the suite showed
gc::tests::root_words::bare_address_in_shadow_slot_survives_a_real_collection
failing (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 main is also 33/33 clean, so I could not attribute it either
way. Recording it rather than dropping it.

Refs #7469.

Summary by CodeRabbit

  • Performance

    • Improved allocation and garbage-collection runtime performance by reducing overhead in frequently used memory-management paths.
    • Optimized page metadata lookups and registry checks for faster execution.
    • Benchmarks show fewer runtime address-resolution operations and improved runtimes.
  • Reliability

    • Preserved existing collector metrics, output, allocation behavior, and memory-management results while applying these optimizations.
  • Documentation

    • Added changelog documentation describing the performance improvements and benchmark results.

…_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.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The 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.

Changes

Hot TLS runtime paths

Layer / File(s) Summary
Hot TLS cache and accessors
crates/perry-runtime/src/tls_hot.rs, crates/perry-runtime/src/gc/hot_tls.rs, crates/perry-runtime/src/lib.rs, crates/perry-runtime/src/gc/{mod.rs,roots.rs}, changelog.d/...
The runtime resolves supported TLS addresses once per thread, stores them in HotTls, and exposes typed cached accessors. Tests verify pointer wiring, initialization, and thread isolation.
Arena allocation and page-generation caching
crates/perry-runtime/src/arena/{allocators.rs,block.rs,mod.rs,page_meta.rs}
Arena allocation and GC free-list access use cached TLS pointers. Page-generation classification uses a four-entry round-robin cache with full invalidation.
GC barrier, layout, and root access
crates/perry-runtime/src/gc/{barrier.rs,hot_tls.rs,layout.rs,roots/temp_roots.rs}
GC barriers use cached state and skip TLS resolution when globally idle. Layout maps and temporary-root operations use cached accessors. Barrier activation publishes the global count before thread-local state.
Map and Set registry short-circuits
crates/perry-runtime/src/{map.rs,set.rs}
Map and Set lookups return early when their registries have never been used, based on monotonic atomic flags.

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
Loading

Possibly related issues

Possibly related PRs

Suggested labels: run-extended-tests

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states that the runtime caches hot thread-locals to reduce _tlv_get_addr calls on the allocation path.
Description check ✅ Passed The description provides detailed summary, changes, issue reference, benchmarks, validation, and test results, although it omits the template headings and checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7469-tls-alloc-path

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between a8dee4b and 20b716f.

📒 Files selected for processing (15)
  • changelog.d/7474-hot-tls-alloc-path.md
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/arena/block.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/page_meta.rs
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/hot_tls.rs
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/gc/roots/temp_roots.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/set.rs
  • crates/perry-runtime/src/tls_hot.rs

Comment on lines +551 to +552
/// Cache-miss arm of [`classify_heap_generation`]: consult the page map and
/// re-prime the one-entry cache.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Suggested change
/// 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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

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:

  1. Every cached slot is the address of a thread-local's own storage (KEY.with(|c| c.get() as *mut u8)), const-init, no Drop — stable for the thread's life. The Vec cases cache the address of the Vec struct, not its data pointer, so buffer reallocation is unaffected. Nothing heap-managed is cached, so no root scanner is owed.
  2. The 4-way page-generation cache keeps the pre-existing invalidation invariant: generation flips only happen through register_block_space/unregister_block_generation, both of which clear all ways; register_old_object_pages changes the object index, not the generation, so its lack of invalidation is correct. The diff removes no invalidation call.
  3. The barrier fast-path veto is semantically additive: when the global count is nonzero it falls through to the exact pre-existing TLS check, and a thread only ever consults its own barrier state, so Relaxed is sufficient; the arm-before-install / clear-before-decrement ordering closes the only window.
  4. Battery: 1716 lib tests, all three tls_hot tests (including per-thread isolation), fmt, raw-handle debt gate, file-size, dev build — all green. 92-test gap sample (json/array/map/gc): 90 pass, 2 failures both proven pre-existing (one reproduced on a pre-PR main build, one the known node_fail).

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 bench_array_ops, so the residual is likely the per-store classification itself rather than TLS resolution — the 4-way cache hit rate under real barrier traffic would be the next number to pull.

@proggeramlug
proggeramlug merged commit f06270d into main Aug 6, 2026
1 of 11 checks passed
@proggeramlug
proggeramlug deleted the perf/7469-tls-alloc-path branch August 6, 2026 04:53
proggeramlug pushed a commit that referenced this pull request Aug 6, 2026
…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.
proggeramlug added a commit that referenced this pull request Aug 6, 2026
… 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>
proggeramlug pushed a commit that referenced this pull request Aug 7, 2026
… 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
proggeramlug added a commit that referenced this pull request Aug 7, 2026
…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>
proggeramlug pushed a commit that referenced this pull request Aug 7, 2026
…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%.
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