Skip to content

perf(tls): thread-locals are on the fast path by default, with a gate that can fail (#7469) - #7758

Merged
proggeramlug merged 9 commits into
mainfrom
perf/7469-tls-context
Aug 10, 2026
Merged

perf(tls): thread-locals are on the fast path by default, with a gate that can fail (#7469)#7758
proggeramlug merged 9 commits into
mainfrom
perf/7469-tls-context

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

_tlv_get_addr was 18-20% of the worst realistic program. The mechanism was fine; the coverage policy was the bug.

On Darwin every thread_local! access is an out-of-line call to _tlv_get_addr
in libdyld. Two good PRs already attacked it — #7474 built the HotTls address
cache, #7565 made reaching that cache free via Apple aarch64's directly
addressable pthread TSD array. Both are preserved here unchanged.

But the measured share kept coming back:

build workload share
after #7565 churn_alloc 0%
later churn_alloc 8-9%
later interp / retain 11%
this PR's base (27d5358d0) asyncpipe 18.2%

HotTls had 16 hand-wired slots against ~520 thread_local! declarations,
and the 16 were curated against whichever workload was profiled last — the
allocation path. churn is covered by construction and reads 0% forever.
asyncpipe pays 18.2% through Map/Set registries, buffer brands, descriptor
state and field-lookup tails that were on nobody's list. Adding a slot took four
manual steps including a hand-written test, and forgetting them produced a
working slow path, not a build error
.

What this lands: option 2, invert the default

crate::perry_thread_local! — same syntax as thread_local!, same with /
try_with at every call site, so converting a declaration converts all of its
uses. The value's address lands in a generic slot of the same per-thread cache,
so a read is loads instead of a call. Nothing to wire: no slot, no provider
function, no line in fill, no line in a test.

It also removes the hazard the old contract needed a test to catch. The untyped
named slots could hand out a correctly-typed reference to the wrong object if
fill was mis-wired; here the storage, the resolver and the key's T all come
from one declaration, so the mis-pairing cannot be expressed.

And it is safer on thread teardown than what it extends. HotCell<T, GUARD>
takes GUARD = needs_drop::<T>() as usize from the macro:

  • RefCell<HashMap<…>> → a one-element guard array whose Drop runs before
    the value's (fields drop in declaration order), un-publishing this thread's
    cached address. A later access falls back and gets std's "accessed during or
    after destruction" panic instead of reading a dropped map.
  • Cell<u64> → a zero-length array, which has no drop glue, so HotCell has
    none: no destructor registered, std's const-init fast path preserved.

The 16 named fields are unchanged and stay a closed set — a fixed offset is
one load cheaper than a claimed slot, and the allocation path is where that
matters. 155 declarations across 51 files use the macro. One more fix in the
same family: js_inline_arena_state was resolving INLINE_STATE/ARENA
through .with() even though both are already named slots — a covered entry
the caller wasn't using (5.2% of interp's remaining calls).

Results (quiet M1 mini, best-of-5 interleaved, absolute seconds)

Base and arm at the same commit (27d5358d0), both built -p perry -p perry-runtime-static -p perry-stdlib-static, isolated target dirs, outputs
verified against node before timing.

_tlv_get_addr share, symbolicated, 8s sample, two runs in agreement:

program base this PR
asyncpipe_big 18.2% 1.9%
interp_big 10.6% 0.4%

Wall clock, interleaved best-of-5:

bench base this PR delta
asyncpipe 1.12 1.06 -5.4%
interp 4.15 4.03 -2.9%
churn 0.42 0.42 0.0%
churn_alloc (control) 0.38 0.38 0.0%
churn_read 0.02 0.02 0.0%
push_cls 0.36 0.36 0.0%
push_num 0.14 0.14 0.0%
cycles 0.19 0.19 0.0%
deeplist 0.25 0.25 0.0%
tree 1.68 1.68 0.0%
tree_wide 2.18 2.17 -0.5%
retain 0.54 0.55 +1.9%
retain_wide 1.11 1.12 +0.9%
fib40 0.40 0.40 0.0%

churn_alloc is the control: its paths were already covered by the sixteen
named fields, so it is exactly the benchmark that must not move. It does not.
The asyncpipe/interp deltas reproduced across three independent interleaved runs
(-6.4%/-7.0%/-5.4% and -3.7%/-3.4%/-2.9%); everything else stayed inside +-2%,
which is this host's noise floor while a second agent was benchmarking on it
(both arms inflated equally by interleaving; absolute seconds run ~3% above the
same host's quiet reference).

The gates

Two, because they fail on different things.

scripts/check_thread_locals.py — structural. A new raw thread_local! in
perry-runtime is a build error unless recorded in
scripts/thread_local_cold_allowlist.json as deliberately cold. The counts
ratchet in both directions: a file that loses a declaration also fails,
because a stale entry is one nobody has to justify any more. It also fails as
declarations approach HOT_SLOT_CAPACITY, since slot exhaustion is correct but
silent. --self-test drives all four rejections.

scripts/tls_budget_gate.sh — outcome, and its design is about vacuity.
Profiling churn_alloc — the benchmark every previous fix was tuned against —
would pass forever while the real cost grew, because churn's thread-locals are
exactly the covered ones: a gate green because its subject never ran. So the
subjects are benchmarks/tls-budget/asyncpipe.ts and interp.ts, and
scripts/tls_budget_check.py refuses a pass unless the run proves it was live:
PERRY_TLS_HOT_STATS=1 must report direct_tsd=1 (otherwise hot() is itself
calling _tlv_get_addr, the mechanism is inert, and a low share would mean the
program resolved nothing) and claimed above a floor no allocation
microbenchmark clears. Seven rejections, all --self-tested, run compiler-free
on every PR.

Sabotage checks

The budget gate goes red on the regression it exists to catch. Two
declarations reverted to raw thread_local!buffer/header.rs's
BUFFER_REGISTRY block and state.rs's STATE_PTR block — rebuilt, gate re-run:

program clean sabotaged budget verdict
asyncpipe 1.8% 7.4% 5.0% FAIL
interp 0.3% 8.6% 3.0% FAIL

GATE_EXIT=1, and the liveness assertions stayed satisfied throughout
(direct_tsd=1, claimed=111/102) — so it failed on the budget, not on a
broken run. Restoring the macro restores GATE_EXIT=0.

A second, unplanned demonstration: while this branch was being rebased, main
added a raw thread_local! to gc/policy.rs. The structural ratchet failed the
build on it immediately, which is exactly the intended workflow — it is recorded
as cold in this PR rather than converted in a file this change does not own.

The teardown guard's test is also sabotage-checked. Neutering
SlotGuard::drop makes teardown_unpublishes_a_dropping_value fail with
left: 2 — it reads the dropped Vec — proving the test detects a real
use-after-free rather than passing vacuously.

Not wired into branch protection

Deliberately. A new gate has never been green, so promoting it immediately would
block every open PR (CLAUDE.md's corollary). That is a maintainer action after
the first observed green run on main — and per the same corollary, not
optional follow-through.

Correctness

  • All outputs byte-identical to node --experimental-strip-types v26.5.1,
    verified before timing, on both arms, for all 15 programs plus the two
    _big profiling variants.
  • Canary iso_miss prints checksum 437840 misses 0 on both arms.
  • cargo test -p perry-runtime --lib: 1995 passed, 0 failed.
  • Thread teardown and multi-thread: converted_declarations_survive_thread_turnover
    drives 64 short-lived threads through the converted registry probes and
    asserts indices are claimed per declaration, not per thread;
    a_generic_slot_is_per_thread, concurrent_first_touch_claims_one_slot_per_declaration
    and teardown_unpublishes_a_dropping_value cover the rest.

Overlap

The three largest _tlv_get_addr callers on asyncpipe are
is_registered_buffer / is_registered_set / is_registered_map — 55% of it —
which is also the subject of the concurrent "remove registry probes from generic
paths" work. The two changes compose (this one makes the resolution cheap; that
one removes the call), but they overlap in measurement: whichever lands second
will show a smaller delta on those symbols. Flagging rather than resolving
unilaterally.

Summary by CodeRabbit

New Features

  • Improved thread-local performance across the runtime while preserving existing behavior.
  • Added automated performance monitoring and budget checks on supported Apple platforms.
  • Added deterministic workloads for validating runtime performance.
  • Added safeguards for cache capacity, cleanup, and thread reuse.

Bug Fixes

  • Improved safety when thread-local values are destroyed or threads are reused.

Documentation

  • Added guidance for tracking thread-local usage and performance budgets.
  • Updated the project version to 0.5.1444.

proggeramlug pushed a commit that referenced this pull request Aug 10, 2026
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4912b585-574f-4aa1-877b-1708ff355ee3

📥 Commits

Reviewing files that changed from the base of the PR and between 31b1a3b and cd12e8e.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/tls_hot.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/tls_hot.rs

📝 Walkthrough

Walkthrough

The PR adds perry_thread_local! as the default cached TLS declaration mechanism, migrates runtime declarations, adds deterministic TLS benchmarks, introduces structural and Darwin profiling gates, and runs these checks through a macOS arm64 GitHub Actions workflow.

Changes

Generic hot TLS

Layer / File(s) Summary
Generic cached TLS implementation
crates/perry-runtime/src/tls_hot.rs
Adds generic slot allocation, typed HotKey access, teardown-safe invalidation, capacity tracking, direct-TSD support, and tests.
Hot TLS integration and direct access
crates/perry-runtime/src/lib.rs, crates/perry-runtime/src/arena/inline.rs, crates/perry-runtime/src/object/collection_proto_thunks.rs, crates/perry-runtime/src/state.rs
Exposes tls_hot, uses cached arena access, updates collection tracking to accept HotKey, and documents consolidated TLS state.
Runtime declaration migration
crates/perry-runtime/src/{array,buffer,closure,exception,gc,map,object,promise,regex,set,string,typedarray,value}/*
Replaces standard thread_local! declarations with perry_thread_local! while preserving stored state and access patterns.

TLS validation and measurement

Layer / File(s) Summary
TLS budget benchmark workloads
benchmarks/tls-budget/*
Adds deterministic asynchronous-processing and tree-walking interpreter workloads with checksum and output validation.
Structural and profiling policy checkers
scripts/check_thread_locals.py, scripts/thread_local_cold_allowlist.json, scripts/tls_budget_check.py, changelog.d/7758-tls-hot-by-default.md
Adds raw-declaration allowlist checks, capacity checks, profile parsing, budget validation, CLI handling, self-tests, and policy documentation.
Darwin profiling gate
scripts/tls_budget_gate.sh
Compiles both fixtures, verifies output, collects sample and TLS statistics, and applies budget checks.
CI workflow enforcement
.github/workflows/tls-budget.yml
Runs self-tests and policy checks on Ubuntu, conditionally profiles relevant changes on macOS arm64, validates artifacts, and uploads profiles.

Release metadata

Layer / File(s) Summary
Workspace version update
Cargo.toml, CLAUDE.md
Updates the workspace and documented version to 0.5.1444.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant RustBuild
  participant TLSBudgetGate
  participant Sample
  participant TLSBudgetCheck
  GitHubActions->>RustBuild: Build Perry and runtime archives
  RustBuild-->>TLSBudgetGate: Release artifacts
  TLSBudgetGate->>TLSBudgetGate: Compile and validate asyncpipe and interp
  TLSBudgetGate->>Sample: Collect runtime profile
  Sample-->>TLSBudgetGate: Return .sample report
  TLSBudgetGate->>TLSBudgetCheck: Submit profile and TLS statistics
  TLSBudgetCheck-->>GitHubActions: Return PASS or FAIL budget result
Loading

Possibly related PRs

Suggested labels: tooling

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main performance change and the addition of a failing TLS budget gate.
Description check ✅ Passed The description provides a detailed summary, concrete changes, test results, performance data, overlap information, and gate status.
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-context

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: 5

🧹 Nitpick comments (2)
scripts/check_thread_locals.py (1)

65-73: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The ratchet only sees brace-delimited macro invocations.

RAW_RE and HOT_RE require { after the macro name. Rust accepts thread_local!(...) and thread_local![...] as well. A raw declaration written with parentheses or brackets passes the checker silently, which is the exact failure mode this script exists to prevent. The same gap makes HOT_RE undercount hot declarations, which weakens the HOT_SLOT_CAPACITY check.

block_bodies also assumes the opening delimiter is {; it needs the matching delimiter pair to stay in sync.

♻️ Suggested change to accept all three delimiters
-RAW_RE = re.compile(r"(?m)^[ \t]*(?:std::)?thread_local!\s*\{")
-HOT_RE = re.compile(r"(?m)^[ \t]*(?:crate::)?perry_thread_local!\s*\{")
+RAW_RE = re.compile(r"(?m)^[ \t]*(?:std::)?thread_local!\s*[\{\(\[]")
+HOT_RE = re.compile(r"(?m)^[ \t]*(?:crate::)?perry_thread_local!\s*[\{\(\[]")

block_bodies then needs to pick the closing delimiter from the opening one:

OPEN_TO_CLOSE = {"{": "}", "(": ")", "[": "]"}


def block_bodies(src: str, pattern: re.Pattern[str]) -> list[str]:
    """Bodies of every macro block `pattern` starts, delimiter-matched."""
    bodies = []
    for m in pattern.finditer(src):
        i = min(
            (src.index(o, m.start()) for o in OPEN_TO_CLOSE if o in src[m.start() : m.end()])
        )
        opener = src[i]
        closer = OPEN_TO_CLOSE[opener]
        depth = 0
        j = i
        while j < len(src):
            if src[j] == opener:
                depth += 1
            elif src[j] == closer:
                depth -= 1
                if depth == 0:
                    break
            j += 1
        bodies.append(src[i + 1 : j])
    return bodies

Add a fifth self_test case that writes thread_local!(static E: u8 = const { 0 }); and asserts verify rejects it.

Do you want me to open an issue to track this?

🤖 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 `@scripts/check_thread_locals.py` around lines 65 - 73, Update RAW_RE and
HOT_RE to match thread_local! and perry_thread_local! invocations using {}, (),
or [] delimiters. Add delimiter-pair handling in block_bodies via an
opening-to-closing mapping so nested bodies are matched correctly, and extend
self_test with a parenthesized raw declaration that verify rejects.
benchmarks/tls-budget/asyncpipe.ts (1)

34-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two documented coverage goals are not exercised.

The comment on Line 57 states that each iteration creates a closure capturing a per-iteration binding. The loop calls handle(req, rates) directly, so no closure captures req or k. The file header on Lines 8-9 makes the same claim.

validate throws only when r.amount <= 0. makeReq sets amount = (i % 97) + 1, so amount is always >= 1. The catch block on Lines 87-90 never runs, and the final errors field of the expected output is a constant 0.

The workload arithmetic itself is correct: I verified the expected 39197275 total and the userSum identity against the gate fixture.

If you want the closure and error paths measured, make them reachable.

♻️ Suggested change to exercise both paths
 async function runBatch(base: number, size: number, rates: Map<string, number>): Promise<Ok[]> {
   // Closures created inside a loop, each capturing a per-iteration binding.
   const jobs: Promise<Ok>[] = [];
   for (let k = 0; k < size; k++) {
     const req = makeReq(base + k);
-    jobs.push(handle(req, rates));
+    const run = (): Promise<Ok> => handle(req, rates);
+    jobs.push(run());
   }

Note that changing the reachable request set changes the expected stdout in scripts/tls_budget_gate.sh.

Also applies to: 56-65

🤖 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 `@benchmarks/tls-budget/asyncpipe.ts` around lines 34 - 37, Update the
benchmark loop around handle and validate so each iteration invokes the work
through a closure that captures that iteration’s req or k binding, as promised
by the file documentation. Adjust makeReq or the generated request set so at
least one request has a non-positive amount, making validate’s throw and the
catch path reachable, then update the expected stdout in
scripts/tls_budget_gate.sh to match the resulting totals and error count.
🤖 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 @.github/workflows/tls-budget.yml:
- Around line 27-32: Update the follow-through documentation around the
tls-budget workflow to explicitly track promoting both tls-budget and
self-test-checkers into branch protection’s required contexts after their first
green run on main. Preserve the existing rationale and reference to the
gc-root-dominance precedent, and record the tracking issue or actionable
maintainer follow-up rather than leaving promotion as an unassigned question.

In `@crates/perry-runtime/src/tls_hot.rs`:
- Around line 1190-1216: Replace the process-global claimed_slots() sampling in
crates/perry-runtime/src/tls_hot.rs:1190-1216 with per-declaration slot_index()
results: have each worker return the three probe indices, then assert they are
in range, distinct, and identical across all eight workers. Apply the same
correction in crates/perry-runtime/src/tls_hot.rs:1300-1313 by removing
after_main/after_workers, capturing each registry declaration’s slot_index() on
the main thread, returning those indices from every worker, and asserting they
match.
- Around line 523-579: Update the exit-time report function in
maybe_install_stats_hook so it does not call blocking claimed_slots(); use a
non-blocking try_lock on CLAIM_LOCK and report an explicit unknown value when
the lock is unavailable, while preserving the existing claimed count when the
lock is acquired.

In `@scripts/tls_budget_check.py`:
- Line 284: Remove the unnecessary f-string prefix from the PASS verdict print
statement in the TLS budget check, leaving it as a regular string literal with
identical output.

In `@scripts/tls_budget_gate.sh`:
- Around line 110-121: Update the profiling block around sample and wait to
remove the `|| true` suppression, delete any existing `$OUT_DIR/$name.sample`
before sampling, and explicitly capture both `sample` and profiled-process
statuses without allowing `set -e` to abort the loop. Report failures, set `rc`,
and continue so every subject reaches `interp` and final aggregation; retain the
empty-sample validation for the current run. Revise the header claim to describe
the explicit status-capture approach instead of claiming the script contains no
`|| true`.

---

Nitpick comments:
In `@benchmarks/tls-budget/asyncpipe.ts`:
- Around line 34-37: Update the benchmark loop around handle and validate so
each iteration invokes the work through a closure that captures that iteration’s
req or k binding, as promised by the file documentation. Adjust makeReq or the
generated request set so at least one request has a non-positive amount, making
validate’s throw and the catch path reachable, then update the expected stdout
in scripts/tls_budget_gate.sh to match the resulting totals and error count.

In `@scripts/check_thread_locals.py`:
- Around line 65-73: Update RAW_RE and HOT_RE to match thread_local! and
perry_thread_local! invocations using {}, (), or [] delimiters. Add
delimiter-pair handling in block_bodies via an opening-to-closing mapping so
nested bodies are matched correctly, and extend self_test with a parenthesized
raw declaration that verify rejects.
🪄 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: 6188c212-993d-425e-84eb-788aac0ef86d

📥 Commits

Reviewing files that changed from the base of the PR and between 411a96e and 282ad29.

📒 Files selected for processing (63)
  • .github/workflows/tls-budget.yml
  • benchmarks/tls-budget/asyncpipe.ts
  • benchmarks/tls-budget/interp.ts
  • changelog.d/7758-tls-hot-by-default.md
  • crates/perry-runtime/src/arena/inline.rs
  • crates/perry-runtime/src/array/element_shape.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/iterator.rs
  • crates/perry-runtime/src/box.rs
  • crates/perry-runtime/src/buffer/detach.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/view.rs
  • crates/perry-runtime/src/closure/alloc.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/closure/registry.rs
  • crates/perry-runtime/src/exception.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/object/arguments.rs
  • crates/perry-runtime/src/object/async_generator_queue.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/class_registry/dispatch.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/collection_proto_thunks.rs
  • crates/perry-runtime/src/object/exotic_expando.rs
  • crates/perry-runtime/src/object/field_get_set/accessors.rs
  • crates/perry-runtime/src/object/field_get_set/field_ops.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-runtime/src/object/global_this/populate.rs
  • crates/perry-runtime/src/object/handle_expando.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/native_module_stream.rs
  • crates/perry-runtime/src/object/native_this_alias.rs
  • crates/perry-runtime/src/object/prop_plan.rs
  • crates/perry-runtime/src/object/prototype_chain.rs
  • crates/perry-runtime/src/object/this_binding.rs
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-runtime/src/promise/combinators.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • crates/perry-runtime/src/promise/mod.rs
  • crates/perry-runtime/src/promise/reactions.rs
  • crates/perry-runtime/src/promise/rejection.rs
  • crates/perry-runtime/src/promise/spec_combinators.rs
  • crates/perry-runtime/src/promise/then.rs
  • crates/perry-runtime/src/regex.rs
  • crates/perry-runtime/src/set.rs
  • crates/perry-runtime/src/state.rs
  • crates/perry-runtime/src/string/format.rs
  • crates/perry-runtime/src/string/intern.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/tls_hot.rs
  • crates/perry-runtime/src/typedarray/mod.rs
  • crates/perry-runtime/src/typedarray_props.rs
  • crates/perry-runtime/src/value/to_string.rs
  • scripts/check_thread_locals.py
  • scripts/thread_local_cold_allowlist.json
  • scripts/tls_budget_check.py
  • scripts/tls_budget_gate.sh

Comment on lines +27 to +32
# 2. NOT wired into branch protection's required contexts by the change that
# adds it -- a new gate has never been green, so promoting it immediately
# would block every open PR (CLAUDE.md's corollary). That is a maintainer
# action after the first observed green run on `main`, and per the
# corollary it is not optional follow-through: `gc-root-dominance` sat red
# on `main` for weeks because the second step was never taken.

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 | 🟠 Major | ⚡ Quick win

Track the branch-protection promotion as required follow-through.

The coding guidelines require a CI gate to be required by branch protection. This workflow is not in the required contexts. The comment states the reason and names the follow-up, and the changelog repeats it. The comment also records that gc-root-dominance sat red on main for weeks because the second step was never taken.

The design supports promotion already: the relevance filter skips steps rather than skipping the job, so the job always concludes on every pull request.

Do you want me to open a tracking issue for adding tls-budget and self-test-checkers to the required contexts after the first green run on main?

As per coding guidelines, "A CI gate must be required by branch protection".

🤖 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 @.github/workflows/tls-budget.yml around lines 27 - 32, Update the
follow-through documentation around the tls-budget workflow to explicitly track
promoting both tls-budget and self-test-checkers into branch protection’s
required contexts after their first green run on main. Preserve the existing
rationale and reference to the gc-root-dominance precedent, and record the
tracking issue or actionable maintainer follow-up rather than leaving promotion
as an unassigned question.

Source: Coding guidelines

Comment on lines +523 to +579
/// How many slots *this thread* has populated.
pub fn published_slots() -> usize {
hot().slots.iter().filter(|s| !s.get().is_null()).count()
}

/// `PERRY_TLS_HOT_STATS=1` — print, at process exit, what this mechanism
/// actually did.
///
/// This exists so a budget gate can assert its subject was LIVE rather than
/// merely quiet. `_tlv_get_addr` reading 0% is the *same observation* whether
/// the cache carried the program's thread-locals or the program simply never
/// resolved one, and #7469's history is that the second case shipped as a pass
/// three times. The line reports:
///
/// * `claimed` — declarations that took a slot process-wide. A program that
/// exercises paths outside the sixteen named fields drives this well past
/// zero; a program that does not, does not.
/// * `published` — slots this thread actually filled.
/// * `direct_tsd` — whether `hot()` is the `mrs`-plus-two-loads path. `0`
/// means the self-check rejected direct addressing and every access is
/// paying `_tlv_get_addr` again, i.e. the whole mechanism is inert.
fn maybe_install_stats_hook() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
if !matches!(
std::env::var("PERRY_TLS_HOT_STATS").as_deref(),
Ok("1") | Ok("on") | Ok("true")
) {
return;
}
extern "C" fn report() {
#[cfg(all(
target_vendor = "apple",
target_arch = "aarch64",
target_pointer_width = "64"
))]
let direct = u8::from(darwin_tsd::active());
#[cfg(not(all(
target_vendor = "apple",
target_arch = "aarch64",
target_pointer_width = "64"
)))]
let direct = 0u8;
eprintln!(
"[tls-hot] claimed={} published={} capacity={} direct_tsd={}",
claimed_slots(),
published_slots(),
HOT_SLOT_CAPACITY,
direct,
);
}
// SAFETY: `report` is `extern "C"`, takes nothing and returns nothing.
unsafe {
libc::atexit(report);
}
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Make the atexit reporter non-blocking on CLAIM_LOCK.

report runs during process exit and calls claimed_slots(), which blocks on CLAIM_LOCK. Other threads can still run at that point. If a thread holds CLAIM_LOCK inside SlotId::claim when exit runs, the reporter blocks forever and the process hangs instead of exiting. The gate script sets PERRY_TLS_HOT_STATS, so a hang here would stall the CI job rather than fail it with a diagnostic.

Use try_lock in the exit path and report an explicit unknown value when the lock is held.

🔒️ Proposed fix for the exit-time lock
+/// As [`claimed_slots`], but never blocks — for the exit reporter, which must
+/// not wait on a thread that is still claiming.
+fn claimed_slots_now() -> Option<u32> {
+    match CLAIM_LOCK.try_lock() {
+        Ok(next) => Some(*next),
+        Err(std::sync::TryLockError::Poisoned(p)) => Some(*p.into_inner()),
+        Err(std::sync::TryLockError::WouldBlock) => None,
+    }
+}
+
         extern "C" fn report() {
@@
             eprintln!(
                 "[tls-hot] claimed={} published={} capacity={} direct_tsd={}",
-                claimed_slots(),
+                claimed_slots_now().map_or(-1i64, i64::from),
                 published_slots(),
                 HOT_SLOT_CAPACITY,
                 direct,
             );
         }
📝 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
/// How many slots *this thread* has populated.
pub fn published_slots() -> usize {
hot().slots.iter().filter(|s| !s.get().is_null()).count()
}
/// `PERRY_TLS_HOT_STATS=1` — print, at process exit, what this mechanism
/// actually did.
///
/// This exists so a budget gate can assert its subject was LIVE rather than
/// merely quiet. `_tlv_get_addr` reading 0% is the *same observation* whether
/// the cache carried the program's thread-locals or the program simply never
/// resolved one, and #7469's history is that the second case shipped as a pass
/// three times. The line reports:
///
/// * `claimed` — declarations that took a slot process-wide. A program that
/// exercises paths outside the sixteen named fields drives this well past
/// zero; a program that does not, does not.
/// * `published` — slots this thread actually filled.
/// * `direct_tsd` — whether `hot()` is the `mrs`-plus-two-loads path. `0`
/// means the self-check rejected direct addressing and every access is
/// paying `_tlv_get_addr` again, i.e. the whole mechanism is inert.
fn maybe_install_stats_hook() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
if !matches!(
std::env::var("PERRY_TLS_HOT_STATS").as_deref(),
Ok("1") | Ok("on") | Ok("true")
) {
return;
}
extern "C" fn report() {
#[cfg(all(
target_vendor = "apple",
target_arch = "aarch64",
target_pointer_width = "64"
))]
let direct = u8::from(darwin_tsd::active());
#[cfg(not(all(
target_vendor = "apple",
target_arch = "aarch64",
target_pointer_width = "64"
)))]
let direct = 0u8;
eprintln!(
"[tls-hot] claimed={} published={} capacity={} direct_tsd={}",
claimed_slots(),
published_slots(),
HOT_SLOT_CAPACITY,
direct,
);
}
// SAFETY: `report` is `extern "C"`, takes nothing and returns nothing.
unsafe {
libc::atexit(report);
}
});
}
/// How many slots *this thread* has populated.
pub fn published_slots() -> usize {
hot().slots.iter().filter(|s| !s.get().is_null()).count()
}
/// As [`claimed_slots`], but never blocks — for the exit reporter, which must
/// not wait on a thread that is still claiming.
fn claimed_slots_now() -> Option<u32> {
match CLAIM_LOCK.try_lock() {
Ok(next) => Some(*next),
Err(std::sync::TryLockError::Poisoned(p)) => Some(*p.into_inner()),
Err(std::sync::TryLockError::WouldBlock) => None,
}
}
/// `PERRY_TLS_HOT_STATS=1` — print, at process exit, what this mechanism
/// actually did.
///
/// This exists so a budget gate can assert its subject was LIVE rather than
/// merely quiet. `_tlv_get_addr` reading 0% is the *same observation* whether
/// the cache carried the program's thread-locals or the program simply never
/// resolved one, and `#7469`'s history is that the second case shipped as a pass
/// three times. The line reports:
///
/// * `claimed` — declarations that took a slot process-wide. A program that
/// exercises paths outside the sixteen named fields drives this well past
/// zero; a program that does not, does not.
/// * `published` — slots this thread actually filled.
/// * `direct_tsd` — whether `hot()` is the `mrs`-plus-two-loads path. `0`
/// means the self-check rejected direct addressing and every access is
/// paying `_tlv_get_addr` again, i.e. the whole mechanism is inert.
fn maybe_install_stats_hook() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
if !matches!(
std::env::var("PERRY_TLS_HOT_STATS").as_deref(),
Ok("1") | Ok("on") | Ok("true")
) {
return;
}
extern "C" fn report() {
#[cfg(all(
target_vendor = "apple",
target_arch = "aarch64",
target_pointer_width = "64"
))]
let direct = u8::from(darwin_tsd::active());
#[cfg(not(all(
target_vendor = "apple",
target_arch = "aarch64",
target_pointer_width = "64"
)))]
let direct = 0u8;
eprintln!(
"[tls-hot] claimed={} published={} capacity={} direct_tsd={}",
claimed_slots_now().map_or(-1i64, i64::from),
published_slots(),
HOT_SLOT_CAPACITY,
direct,
);
}
// SAFETY: `report` is `extern "C"`, takes nothing and returns nothing.
unsafe {
libc::atexit(report);
}
});
}
🤖 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/tls_hot.rs` around lines 523 - 579, Update the
exit-time report function in maybe_install_stats_hook so it does not call
blocking claimed_slots(); use a non-blocking try_lock on CLAIM_LOCK and report
an explicit unknown value when the lock is unavailable, while preserving the
existing claimed count when the lock is acquired.

Comment on lines +1190 to +1216
#[test]
fn concurrent_first_touch_claims_one_slot_per_declaration() {
let before = super::claimed_slots();
let workers: Vec<_> = (0..8)
.map(|_| {
std::thread::spawn(|| {
PROBE_CONST.with(|c| c.get());
PROBE_EXPR.with(|v| v.borrow().len());
PROBE_SECOND.with(|c| c.get());
})
})
.collect();
for w in workers {
w.join().expect("probe thread panicked");
}
let after = super::claimed_slots();
assert!(
after - before <= 3,
"8 threads first-touching 3 declarations claimed {} slots",
after - before
);
assert!(
(after as usize) < super::HOT_SLOT_CAPACITY,
"slot capacity {} exhausted at {after} claims",
super::HOT_SLOT_CAPACITY
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Both new capacity tests infer per-test behavior from the process-global claimed_slots() counter. The test harness runs tests in parallel inside one process, so any other test that first-touches a converted declaration changes the counter inside the sampling window. Assert on slot_index() values, which are per-declaration and stable, instead of on counter deltas.

  • crates/perry-runtime/src/tls_hot.rs#L1190-L1216: drop the before/after samples. Return slot_index() for the three probes from each worker and assert the indices are in range, distinct, and identical across all eight workers.
  • crates/perry-runtime/src/tls_hot.rs#L1300-L1313: drop after_main/after_workers. Capture each registry declaration's slot_index() on the main thread, return the same indices from every worker, and assert they match.
📍 Affects 1 file
  • crates/perry-runtime/src/tls_hot.rs#L1190-L1216 (this comment)
  • crates/perry-runtime/src/tls_hot.rs#L1300-L1313
🤖 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/tls_hot.rs` around lines 1190 - 1216, Replace the
process-global claimed_slots() sampling in
crates/perry-runtime/src/tls_hot.rs:1190-1216 with per-declaration slot_index()
results: have each worker return the three probe indices, then assert they are
in range, distinct, and identical across all eight workers. Apply the same
correction in crates/perry-runtime/src/tls_hot.rs:1300-1313 by removing
after_main/after_workers, capturing each registry declaration’s slot_index() on
the main thread, returning those indices from every worker, and asserting they
match.

for sym, n in profile.callers.most_common(8):
print(f" {n:6d} {sym}")
if ok:
print(f" VERDICT : PASS")

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

Remove the extraneous f prefix.

The string has no placeholders. Ruff reports F541 here.

🔧 Proposed fix
-        print(f"    VERDICT      : PASS")
+        print("    VERDICT      : PASS")

As per static analysis hints, Ruff (0.16.1) flags f-string without any placeholders on this line.

📝 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
print(f" VERDICT : PASS")
print(" VERDICT : PASS")
🧰 Tools
🪛 Ruff (0.16.1)

[error] 284-284: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 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 `@scripts/tls_budget_check.py` at line 284, Remove the unnecessary f-string
prefix from the PASS verdict print statement in the TLS budget check, leaving it
as a regular string literal with identical output.

Source: Linters/SAST tools

Comment on lines +110 to +121
echo "--- profiling $name (${SAMPLE_SECONDS}s)"
PERRY_TLS_HOT_STATS=1 "$exe" >/dev/null 2>"$OUT_DIR/$name.stats" &
pid=$!
sleep 1
sample "$pid" "$SAMPLE_SECONDS" -f "$OUT_DIR/$name.sample" >/dev/null 2>&1 || true
wait "$pid"

if [[ ! -s "$OUT_DIR/$name.sample" ]]; then
echo "tls-budget: sample produced no report for $name" >&2
rc=1
continue
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The profiling block loses two failure signals and contradicts the header.

Line 114 uses || true. The header on Line 17 states the script contains no || true. The empty-file check on Line 117 is the only backstop, and it is not sufficient: OUT_DIR is caller-supplied and the workflow passes a fixed path, so a stale $name.sample from a previous invocation satisfies -s and the gate returns a verdict on an old profile.

Line 115 runs wait "$pid" as a simple command under set -e. If the profiled program exits non-zero, the script terminates on that line. The loop then never reaches interp, rc aggregation is skipped, and the final exit "$rc" never runs. The job goes red, so no false pass occurs, but one subject is dropped without a message.

🔧 Proposed fix
     echo "--- profiling $name (${SAMPLE_SECONDS}s)"
+    rm -f "$OUT_DIR/$name.sample"
     PERRY_TLS_HOT_STATS=1 "$exe" >/dev/null 2>"$OUT_DIR/$name.stats" &
     pid=$!
     sleep 1
-    sample "$pid" "$SAMPLE_SECONDS" -f "$OUT_DIR/$name.sample" >/dev/null 2>&1 || true
-    wait "$pid"
+    sample_rc=0
+    sample "$pid" "$SAMPLE_SECONDS" -f "$OUT_DIR/$name.sample" >/dev/null 2>&1 || sample_rc=$?
+    run_rc=0
+    wait "$pid" || run_rc=$?
+    if [[ "$run_rc" -ne 0 ]]; then
+        echo "tls-budget: the profiled run of $name exited $run_rc" >&2
+        rc=1
+        continue
+    fi
+    if [[ "$sample_rc" -ne 0 ]]; then
+        echo "tls-budget: sample exited $sample_rc for $name" >&2
+        rc=1
+        continue
+    fi
 
     if [[ ! -s "$OUT_DIR/$name.sample" ]]; then

Then update the header claim on Line 17 to describe the explicit status capture.

📝 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
echo "--- profiling $name (${SAMPLE_SECONDS}s)"
PERRY_TLS_HOT_STATS=1 "$exe" >/dev/null 2>"$OUT_DIR/$name.stats" &
pid=$!
sleep 1
sample "$pid" "$SAMPLE_SECONDS" -f "$OUT_DIR/$name.sample" >/dev/null 2>&1 || true
wait "$pid"
if [[ ! -s "$OUT_DIR/$name.sample" ]]; then
echo "tls-budget: sample produced no report for $name" >&2
rc=1
continue
fi
echo "--- profiling $name (${SAMPLE_SECONDS}s)"
rm -f "$OUT_DIR/$name.sample"
PERRY_TLS_HOT_STATS=1 "$exe" >/dev/null 2>"$OUT_DIR/$name.stats" &
pid=$!
sleep 1
sample_rc=0
sample "$pid" "$SAMPLE_SECONDS" -f "$OUT_DIR/$name.sample" >/dev/null 2>&1 || sample_rc=$?
run_rc=0
wait "$pid" || run_rc=$?
if [[ "$run_rc" -ne 0 ]]; then
echo "tls-budget: the profiled run of $name exited $run_rc" >&2
rc=1
continue
fi
if [[ "$sample_rc" -ne 0 ]]; then
echo "tls-budget: sample exited $sample_rc for $name" >&2
rc=1
continue
fi
if [[ ! -s "$OUT_DIR/$name.sample" ]]; then
echo "tls-budget: sample produced no report for $name" >&2
rc=1
continue
fi
🤖 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 `@scripts/tls_budget_gate.sh` around lines 110 - 121, Update the profiling
block around sample and wait to remove the `|| true` suppression, delete any
existing `$OUT_DIR/$name.sample` before sampling, and explicitly capture both
`sample` and profiled-process statuses without allowing `set -e` to abort the
loop. Report failures, set `rc`, and continue so every subject reaches `interp`
and final aggregation; retain the empty-sample validation for the current run.
Revise the header claim to describe the explicit status-capture approach instead
of claiming the script contains no `|| true`.

@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: 4

🤖 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/tls_hot.rs`:
- Around line 642-649: Update SlotGuard::drop so teardown never calls hot()
after TLS storage has been destroyed, avoiding the hot_via_tls()
initialization/fill path during late destructors. Preserve and use the published
HotTls pointer during teardown, or otherwise clear the slot directly without
triggering hot() initialization, while retaining the existing bounds check and
slot-clearing behavior.

In `@scripts/check_thread_locals.py`:
- Around line 118-120: Update the raw TLS counting logic around RAW_RE so each
matched macro block contributes the number of DECL_RE matches within its body,
rather than counting blocks themselves; retain the existing per-file raw[rel]
aggregation used by verify(). Extend the self-test fixture with a second static
declaration inside one allowlisted thread_local! block and update its expected
count.

In `@scripts/tls_budget_gate.sh`:
- Around line 57-62: Normalize OUT_DIR to an absolute path immediately after its
initial assignment, before mkdir, executable-path construction, or any later cd.
Preserve the caller’s relative directory while ensuring compilation and
subsequent executable checks resolve the same location.
- Around line 101-107: Guard the correctness execution in the actual assignment
so a non-zero exit from "$exe" does not trigger set -e in the surrounding loop.
Preserve the existing mismatch check, set rc=1, and continue processing the
remaining entries, matching the failure-handling behavior of the compilation
path.
🪄 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: 23a58f25-1820-4820-8e96-753c8068441b

📥 Commits

Reviewing files that changed from the base of the PR and between 411a96e and 282ad29.

📒 Files selected for processing (63)
  • .github/workflows/tls-budget.yml
  • benchmarks/tls-budget/asyncpipe.ts
  • benchmarks/tls-budget/interp.ts
  • changelog.d/7758-tls-hot-by-default.md
  • crates/perry-runtime/src/arena/inline.rs
  • crates/perry-runtime/src/array/element_shape.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/iterator.rs
  • crates/perry-runtime/src/box.rs
  • crates/perry-runtime/src/buffer/detach.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/buffer/view.rs
  • crates/perry-runtime/src/closure/alloc.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/closure/registry.rs
  • crates/perry-runtime/src/exception.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/object/arguments.rs
  • crates/perry-runtime/src/object/async_generator_queue.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/object/class_registry/dispatch.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/collection_proto_thunks.rs
  • crates/perry-runtime/src/object/exotic_expando.rs
  • crates/perry-runtime/src/object/field_get_set/accessors.rs
  • crates/perry-runtime/src/object/field_get_set/field_ops.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-runtime/src/object/global_this/populate.rs
  • crates/perry-runtime/src/object/handle_expando.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/native_module_stream.rs
  • crates/perry-runtime/src/object/native_this_alias.rs
  • crates/perry-runtime/src/object/prop_plan.rs
  • crates/perry-runtime/src/object/prototype_chain.rs
  • crates/perry-runtime/src/object/this_binding.rs
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-runtime/src/promise/combinators.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • crates/perry-runtime/src/promise/mod.rs
  • crates/perry-runtime/src/promise/reactions.rs
  • crates/perry-runtime/src/promise/rejection.rs
  • crates/perry-runtime/src/promise/spec_combinators.rs
  • crates/perry-runtime/src/promise/then.rs
  • crates/perry-runtime/src/regex.rs
  • crates/perry-runtime/src/set.rs
  • crates/perry-runtime/src/state.rs
  • crates/perry-runtime/src/string/format.rs
  • crates/perry-runtime/src/string/intern.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/tls_hot.rs
  • crates/perry-runtime/src/typedarray/mod.rs
  • crates/perry-runtime/src/typedarray_props.rs
  • crates/perry-runtime/src/value/to_string.rs
  • scripts/check_thread_locals.py
  • scripts/thread_local_cold_allowlist.json
  • scripts/tls_budget_check.py
  • scripts/tls_budget_gate.sh
🚧 Files skipped from review as they are similar to previous changes (58)
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/array/element_shape.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/string/format.rs
  • crates/perry-runtime/src/string/intern.rs
  • crates/perry-runtime/src/object/arguments.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-runtime/src/promise/mod.rs
  • crates/perry-runtime/src/typedarray_props.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/object/handle_expando.rs
  • crates/perry-runtime/src/promise/then.rs
  • crates/perry-runtime/src/object/class_registry/construct.rs
  • crates/perry-runtime/src/exception.rs
  • crates/perry-runtime/src/object/async_generator_queue.rs
  • crates/perry-runtime/src/closure/alloc.rs
  • crates/perry-runtime/src/closure/registry.rs
  • crates/perry-runtime/src/regex.rs
  • crates/perry-runtime/src/object/field_get_set/accessors.rs
  • crates/perry-runtime/src/arena/inline.rs
  • crates/perry-runtime/src/state.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/typedarray/mod.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/object/class_registry/dispatch.rs
  • crates/perry-runtime/src/promise/rejection.rs
  • crates/perry-runtime/src/promise/reactions.rs
  • crates/perry-runtime/src/array/iterator.rs
  • benchmarks/tls-budget/asyncpipe.ts
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-runtime/src/buffer/detach.rs
  • crates/perry-runtime/src/object/this_binding.rs
  • crates/perry-runtime/src/object/native_module_stream.rs
  • crates/perry-runtime/src/object/global_this/populate.rs
  • crates/perry-runtime/src/object/native_this_alias.rs
  • crates/perry-runtime/src/promise/spec_combinators.rs
  • crates/perry-runtime/src/buffer/view.rs
  • crates/perry-runtime/src/object/exotic_expando.rs
  • crates/perry-runtime/src/set.rs
  • scripts/thread_local_cold_allowlist.json
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-runtime/src/object/prop_plan.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • benchmarks/tls-budget/interp.ts
  • crates/perry-runtime/src/object/collection_proto_thunks.rs
  • crates/perry-runtime/src/object/prototype_chain.rs
  • crates/perry-runtime/src/box.rs
  • .github/workflows/tls-budget.yml
  • crates/perry-runtime/src/promise/combinators.rs
  • crates/perry-runtime/src/value/to_string.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/object/field_get_set/field_ops.rs

Comment on lines +642 to +649
impl Drop for SlotGuard {
fn drop(&mut self) {
let idx = self.idx.get();
if (idx as usize) < HOT_SLOT_CAPACITY {
hot().set_slot(idx, std::ptr::null_mut());
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect hot() and fill() for lazy per-call initialization reachable from a destructor.
set -euo pipefail

ast-grep outline crates/perry-runtime/src/tls_hot.rs --items all
sed -n '200,445p' crates/perry-runtime/src/tls_hot.rs

Repository: PerryTS/perry

Length of output: 13473


🏁 Script executed:

#!/bin/bash
# Description: Inspect all thread-local accessors used by fill, HotKey, and HotCell to determine destructor reachability.
set -euo pipefail

# Locate the hot_addr definitions and HotTls fields used in the initial inspection.
rg -n "hot_addr|temp_roots|layout_slot_masks|gc::|arena::|object::" crates -g '*.rs' | head -n 200 || true

# Map each field written by fill to its accessor definition/source module if present.
python3 - <<'PY'
from pathlib import Path
import re

src = Path("crates/perry-runtime/src/tls_hot.rs").read_text()
fields = [
    "arena", "inline_state", "page_generation_cache", "page_generations",
    "arena_free_list", "arena_free_list_nonempty", "birth_extra_flags",
    "incremental_mark_valid_ptrs", "incremental_mark_minor_only",
    "layout_slot_masks", "typed_layouts", "shape_layouts",
    "per_object_layouts_nonempty", "shape_install_memo",
    "learned_inline_fields", "temp_roots",
]
for f in fields:
    print(f"--- {f} ---")
    for line in src.splitlines():
        if re.search(rf"\b({re.escape(f)}\s*=\s*)", line):
            print(line)
PY

# Show all occurrences and likely definitions for the hot_addr helpers.
rg -n "pub(crate)(:)? fn .*_hot_addr|fn .*_hot_addr|thread_local|thread_local!" crates -g '*.rs' | head -n 250 || true

# Static check: whether hot_uncached calls hot_via_tls, and how hot_via_tls fills.
python3 - <<'PY'
from pathlib import Path
text = Path("crates/perry-runtime/src/tls_hot.rs").read_text()
print("has_hot_uncached_calls_hot_via_tls:", "let slots = hot_via_tls();" in text[text.find("fn hot_uncached"):text.find("/// The per-thread address cache. On Apple", text.find("fn hot_uncached"))])
print("has_hot_via_tls_fills_on_first_use:", "if (*slots).temp_roots.is_null() {" in text and "fill(slots);" in text[text.find("fn hot_via_tls"):text.find("/// Direct pthread thread-specific-data addressing", text.find("fn hot_via_tls"))])
PY

Repository: PerryTS/perry

Length of output: 43632


🏁 Script executed:

#!/bin/bash
# Description: Find definitions of all _hot_addr helpers used by fill().
set -euo pipefail
for mod in arena gc object; do
  echo "--- $mod/module ---"
  fd -a 'mod\.rs$' crates/perry-runtime/src/$mod 2>/dev/null | sort
done

echo "--- definitions search ---"
rg -n "pub\(crate\) fn (arena|inline_state|page_generation_cache|page_generations|(arena_free_list|birth_extra_flags|incremental_mark_(valid_ptrs|minor_only)|incremental_mark_minor_only|layout_slot_masks|typed_layouts|shape_layouts|per_object_layouts_nonempty|shape_install_memo|learned_inline_fields|temp_roots|gc_write_barriers_emitted|js_shadow_frame_push|js_shadow_slot_set)_hot_addr)|fn .*_hot_addr" crates/perry-runtime/src crates/perry-codegen/src -g '*.rs' || true

Repository: PerryTS/perry

Length of output: 2791


🏁 Script executed:

#!/bin/bash
# Description: Inspect the bodies of all _hot_addr accessors used by fill().
set -euo pipefail

for f in \
  crates/perry-runtime/src/arena/block.rs \
  crates/perry-runtime/src/arena/page_meta.rs \
  crates/perry-runtime/src/object/spill.rs \
  crates/perry-runtime/src/gc/fmt.rs \
  crates/perry-runtime/src/gc/shape_install.rs \
  crates/perry-runtime/src/gc/roots/temp_roots.rs \
  crates/perry-runtime/src/arena/fence.rs \
  crates/perry-runtime/src/arena/block_cache.rs \
  crates/perry-runtime/src/arena/mod.rs
do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n -A 20 -B 3 "(arena_hot_addr|inline_state_hot_addr|page_generation_cache_hot_addr|page_generations_hot_addr|birth_extra_flags_hot_addr|incremental_mark_valid_ptrs_hot_addr|incremental_mark_minor_only_hot_addr|layout_slot_masks_hot_addr|typed_layouts_hot_addr|shape_layouts_hot_addr|per_object_layouts_nonempty_hot_addr|shape_install_memo_hot_addr|arena_free_list_hot_addr|arena_free_list_nonempty_hot_addr|learned_inline_fields_hot_addr|temp_roots_hot_addr|js_gc_write_barriers_emitted_hot_addr|java_global_slot_hot_addr|thread_slot_hot_addr|perry_thread_local)" "$f" || echo "no matches"
  fi
done

echo "--- macro helpers ---"
rg -n -A 25 -B 5 "macro_rules! __perry_thread_local" crates/perry-runtime/src/tls_hot.rs

Repository: PerryTS/perry

Length of output: 11766


Keep hot() destructor calls from filling after TLS teardown.

On non-Darwin, hot() routes through hot_via_tls(), whose normal first call runs fill() and resolves many thread-local providers. If SlotGuard::drop() can run after those providers are destroyed, it aborts inside a destructor. Preserve the published HotTls pointer for teardown, or replace the destructor behavior with no hot() fill after TLS storage is gone.

🤖 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/tls_hot.rs` around lines 642 - 649, Update
SlotGuard::drop so teardown never calls hot() after TLS storage has been
destroyed, avoiding the hot_via_tls() initialization/fill path during late
destructors. Preserve and use the published HotTls pointer during teardown, or
otherwise clear the slot directly without triggering hot() initialization, while
retaining the existing bounds check and slot-clearing behavior.

Comment on lines +118 to +120
count = len(RAW_RE.findall(src))
if count:
raw[rel] = count

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Count raw TLS declarations instead of macro blocks.

Lines 118-120 count thread_local! macro blocks. A contributor can add another static declaration inside an existing allowlisted block. The count does not change, so verify() accepts the new raw declaration.

Count DECL_RE matches inside each RAW_RE block. Update the self-test to add a second static in one macro block.

Proposed fix
-                count = len(RAW_RE.findall(src))
+                count = sum(
+                    len(DECL_RE.findall(body)) for body in block_bodies(src, RAW_RE)
+                )
-            "thread_local! { static A: u8 = const { 0 }; }\n"
-            "thread_local! { static D: u8 = const { 0 }; }\n"
+            "thread_local! {\n"
+            "    static A: u8 = const { 0 };\n"
+            "    static D: u8 = const { 0 };\n"
+            "}\n"

Also applies to: 230-236

🤖 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 `@scripts/check_thread_locals.py` around lines 118 - 120, Update the raw TLS
counting logic around RAW_RE so each matched macro block contributes the number
of DECL_RE matches within its body, rather than counting blocks themselves;
retain the existing per-file raw[rel] aggregation used by verify(). Extend the
self-test fixture with a second static declaration inside one allowlisted
thread_local! block and update its expected count.

Comment on lines +57 to +62
OUT_DIR="${2:-$(mktemp -d)}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FIXTURES="$REPO_ROOT/benchmarks/tls-budget"

mkdir -p "$OUT_DIR"
PERRY_BIN="$(cd "$(dirname "$PERRY_BIN")" && pwd)/$(basename "$PERRY_BIN")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize OUT_DIR before compiling.

If the caller passes a relative output directory, Line 90 changes into that directory but exe still contains the relative prefix from Line 81. The -o path and the later executable check then resolve relative to different working directories. For example, tmp/tls/asyncpipe resolves as tmp/tls/tmp/tls/asyncpipe during compilation.

Convert OUT_DIR to an absolute path after Line 61.

Proposed fix
 mkdir -p "$OUT_DIR"
+OUT_DIR="$(cd "$OUT_DIR" && pwd -P)"
 PERRY_BIN="$(cd "$(dirname "$PERRY_BIN")" && pwd)/$(basename "$PERRY_BIN")"

Also applies to: 80-90

🤖 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 `@scripts/tls_budget_gate.sh` around lines 57 - 62, Normalize OUT_DIR to an
absolute path immediately after its initial assignment, before mkdir,
executable-path construction, or any later cd. Preserve the caller’s relative
directory while ensuring compilation and subsequent executable checks resolve
the same location.

Comment on lines +101 to +107
# Correctness BEFORE timing: a program that prints the wrong answer is not
# a faster program.
actual="$("$exe")"
if [[ "$actual" != "$expected" ]]; then
echo "tls-budget: $name printed '$actual', expected '$expected'" >&2
rc=1
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Capture correctness-run failures without aborting the loop.

Because set -e is enabled on Line 54, a non-zero exit from Line 103 terminates the script immediately. The script does not update rc, profile interp, or reach the final aggregation.

Guard the assignment, set rc=1, and continue as in the compilation path.

Proposed fix
-    actual="$("$exe")"
+    if ! actual="$("$exe")"; then
+        echo "tls-budget: $name correctness run failed" >&2
+        rc=1
+        continue
+    fi
📝 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
# Correctness BEFORE timing: a program that prints the wrong answer is not
# a faster program.
actual="$("$exe")"
if [[ "$actual" != "$expected" ]]; then
echo "tls-budget: $name printed '$actual', expected '$expected'" >&2
rc=1
continue
# Correctness BEFORE timing: a program that prints the wrong answer is not
# a faster program.
if ! actual="$("$exe")"; then
echo "tls-budget: $name correctness run failed" >&2
rc=1
continue
fi
if [[ "$actual" != "$expected" ]]; then
echo "tls-budget: $name printed '$actual', expected '$expected'" >&2
rc=1
continue
🤖 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 `@scripts/tls_budget_gate.sh` around lines 101 - 107, Guard the correctness
execution in the actual assignment so a non-zero exit from "$exe" does not
trigger set -e in the surrounding loop. Preserve the existing mismatch check,
set rc=1, and continue processing the remaining entries, matching the
failure-handling behavior of the compilation path.

@proggeramlug
proggeramlug force-pushed the perf/7469-tls-context branch from 282ad29 to 31b1a3b Compare August 10, 2026 10:34
…sertion

claimed_slots() is process-global, so another test's first touch of a
converted declaration lands inside the measurement window. Equality failed
6/6 under load while passing in isolation and single-threaded. The tolerance
still separates the outcomes by two orders of magnitude: per-thread claiming
would add 5 x 64 = 320, not 1.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1444, with one test fixed on the branch

The framing is the finding

The mechanism was fine; the coverage policy was the bug.

16 hand-wired slots against ~520 declarations, curated against whichever workload was profiled last — so churn_alloc reads 0% forever while asyncpipe pays 18.2% through Map/Set registries, buffer brands and field-lookup tails that were on nobody's list. And adding a slot took four manual steps where forgetting them produced a working slow path, not a build error. That is the whole reason the number kept coming back after two good PRs had already fixed it.

Inverting the default removes the curation entirely, and it also removes a hazard the old contract needed a test to catch: untyped named slots could hand a correctly-typed reference to the wrong object if fill was mis-wired. With storage, resolver and key T all from one declaration, the mis-pairing is inexpressible.

The teardown reasoning is the subtlest part and it is right. GUARD = needs_drop::<T>() gives a droppable value a one-element guard array whose Drop runs before the value's — fields drop in declaration order — so the cached address is un-published and a later access gets std's "accessed during or after destruction" panic instead of reading a dropped map. Cell<u64> gets a zero-length array, no drop glue, and std's const-init fast path is preserved. Verified the test bites: neutering SlotGuard::drop fails teardown_unpublishes_a_dropping_value.

Both gates verified

  • Structural: planting a raw thread_local! fails immediately, and the message names the fix and the allowlist. Ratcheting in both directions is right — a stale entry is one nobody has to justify any more. --self-test: "the checker can fail in all four directions."
  • Outcome: --self-test: "the checker rejects all 7 failure modes." Its design against vacuity is the important half — profiling churn_alloc would pass forever while the real cost grew, because churn's thread-locals are exactly the covered ones. Requiring direct_tsd=1 and a claimed floor no allocation microbenchmark clears is what stops "a gate green because its subject never ran".

The sabotage failing on the budget while the liveness assertions stayed satisfied (direct_tsd=1, claimed=111/102) is the distinction that matters — it failed for the right reason, not because the run broke.

And the unplanned demonstration is the best evidence of all: the ratchet failed the build on a raw thread_local! that landed in gc/policy.rs while this branch was being rebased — one of my merges. Recording it as cold rather than converting a file this PR doesn't own is the right call.

The one thing I changed

converted_declarations_survive_thread_turnover failed 6/6 under load while passing 3/3 in isolation and single-threaded. Cause: claimed_slots() is process-global, so any other test's first touch of a converted declaration lands a claim inside the measurement window, and the assertion was exact equality (left: 138, right: 139).

Replaced with a tolerance of 8, documented. It still separates the outcomes by two orders of magnitude — per-thread claiming, the bug it exists to catch, would add 5 declarations × 64 workers = 320, not 1. 0/4 failures after, versus 6/6 before.

churn_alloc as the control — the benchmark that must not move because it was already covered — not moving is what makes the asyncpipe/interp deltas believable. cargo test -p perry-runtime --lib: 2023 passed. Gates 21/21.

@proggeramlug
proggeramlug merged commit ede4b70 into main Aug 10, 2026
1 of 19 checks passed
@proggeramlug
proggeramlug deleted the perf/7469-tls-context branch August 10, 2026 10:50
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