Skip to content

perf(gc): route the trigger path through hot TLS — and fix the gate that counts blocks where the cost is per declaration - #9827

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/gc-overhead
Closed

perf(gc): route the trigger path through hot TLS — and fix the gate that counts blocks where the cost is per declaration#9827
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/gc-overhead

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Two changes, and the second one is probably the more important. The perf
change is a routine hot-TLS conversion with a proven mechanism and a flat rig
result. The other is a lint that has been reporting a number nobody could
act on
: scripts/check_thread_locals.py ratchets on raw thread_local!
blocks, while the cost it exists to bound is paid per declaration. Every
"thread-local policy OK: N hot declarations, M raw blocks" line quoted in a
PR body this campaign was comparing unlike units, and the cold side was
understated roughly threefold. See The gate was counting the wrong unit
below.

What

gc_check_trigger runs on every gc_malloc, and gc_budgeted_due_trigger
resolved eleven raw thread_local! declarations one _tlv_get_addr call at a
time. crates/perry-runtime/src/tls_hot.rs has existed to abolish exactly this
since #7469; the allocation path's named fields were covered and the trigger
path never was. Sixty-seven declarations across gc/policy.rs, gc/malloc.rs,
gc/old_free.rs, gc/tenuring.rs, gc/trace.rs, gc/barrier/mod.rs,
arena/block.rs and arena/page_meta.rs move to crate::perry_thread_local!.

Part of the broader half of #9740.

The gate was counting the wrong unit — and that is why this cost came back

_tlv_get_addr is paid per declaration read. scripts/check_thread_locals.py
ratchets on the number of raw thread_local! blocks per file, and
thread_local! { … } holds any number of declarations. A ratchet denominated
in blocks cannot see the quantity it exists to bound.

Three consequences, all reproduced on main at d36a1af0c:

  1. A static added to an already-recorded block passes the gate in
    silence.
    A second block in a listed file is caught; a second
    declaration inside the block that is already listed is not. That is the
    hole through which the allocation path re-accumulated exactly the cost
    perf(runtime): allocation path spends 34% of self time in _tlv_get_addr — 24× behind Node on object churn with the collector already idle #7469 removed — no commit had to defeat the gate, only to add a line inside
    an existing brace.
  2. The recorded numbers were not the real ones. gc/policy.rs was
    recorded as 6 while declaring 28. arena/block.rs went from 11 cold
    declarations to 0 in this PR with its recorded block count unchanged.
  3. The gate's own summary line was misleading, and everyone quoted it.
    Counted in the hot side's unit — the hot side was always counted in
    declarations — main was 318 hot declarations against 339 cold ones.
    Cold was the majority. The line printed instead was "318 hot
    declarations, 122 raw blocks", which reads as a 2.6:1 majority for the
    mechanism. Every "thread-local policy OK" quoted in a campaign PR body this
    month was comparing unlike units and understating the cold side ~3×.

The gate now ratchets on declarations (385 hot / 272 cold — a real and much
less comfortable number), every message says "declaration(s)", and the
allowlist _comment states that adding a static to a listed file fails
"whether or not it opens a new block".

--self-test gained a seventh direction for case (1): a static added to an
already-recorded block. It is sabotage-proved — restoring the block count makes
that assertion, and only that assertion, fail — so the hole cannot silently
reopen.

Measurement

Rig: secret-tests/cc-permission-harness, compiled claude-code TUI, offline
mock API, sandbox ov. Baseline cc_relink/cc_main_0905 (main d36a1af0c).
Candidate /tmp/cc_ov_tls, relinked from this branch (277 s object-cache hit).

The falsifier, registered before the candidate was built

sample, 3300-char reply, chunk 50; leaf (self) samples with callers resolved
by an explicit ancestor walk (secret-tests/cc-perf-campaign/tlv_an.py), not
nearest-symbol labels.

_tlv_get_addr leaf samples threshold set in advance base candidate
immediate caller gc_budgeted_due_trigger ≤ 10 71 1
ancestor gc_check_trigger anywhere ≤ 78 163 9
main-thread total ≤ 250 380 110
share of active main-thread samples ≤ 1.8 % 2.61 % 0.80 %

Per-caller, all to zero: old_page_account_dirty_slots 36 → 0,
gc_malloc_header_is_tracked 27 → 0, mark_addr 22 → 0, dead_owner 16 → 0,
mark_mutable_root_bits 15 → 0, old_free_take_exact 9 → 0,
old_page_account_swept_object 10 → 0. _tlv_get_addr leaves the top-three
leaf symbols entirely.

The schedule is flat — stated as a prediction, not found as a consolation

PERRY_GC_DIAG=1, 400-char reply, both arms:

counter base candidate
[gc-copy-minor] lines 78 78
[gc-step] (budgeted full-cycle steps) 43 43
[gc-restore-coverage] 48 48
[gc-scanner-profile] 585 585
copied objects / bytes (first minor) 162,061 / 12,026,744 162,063 / 12,029,056

This removes no allocation, so it must not move the collector, and it does not.

Rig table — 3 interleaved rounds (abn.py), minima, node arm same session

arm 400 cpu min 400 idle12 3300 cpu min 3300 idle12 FP settled 400 (3 runs) FP settled 3300 (3 runs) peak RSS 3300
base 4.15 1.56 17.71 5.14 504/480/558 1173/1203/761 1319/1315/1324
cand 4.21 1.66 17.80 10.61 481/556/560 757/1055/685 982/1325/1226
node 0.27 0.01 0.51 0.02 170/168/325 210/211/209 405–411

timed_turn, one run each: startup 1.90 / 1.65 / 1.23 s (base/cand/node);
typing CPU r2 0.95 / 0.66 / 0.20 s; echo p90 17 / 18 / 3 ms; turn CPU r2
0.79 / 0.87 / 0.07 s; RSS r2 517 / 553 / 368 MB.

The rig CPU delta is flat, and that is the reading I am publishing. The candidate's
minimum at 3300 was taken at load 7.5 and the baseline's at load 10.6, so the
comparison does not even favour the candidate; the arms are inconclusive at
this load. 1.8 % of leaf samples is well under the rig's own within-arm spread
(the 400 arm ranges 4.15–5.26 s). Footprint and peak RSS do not regress —
bimodal, so all three runs are printed.

The ground claimed for a flat result

Work permanently removed, measured: _tlv_get_addr 2.61 % → 0.80 % of
active main-thread samples on the same workload, and the trigger path's share
of it 163 → 9. A defect fixed: the policy gate could not see the quantity
it exists to bound, and now can.

Three declarations stay raw, and it is not an oversight

ARENA_TOTAL_BYTES, BLOCK_POOL and BLOCK_POOL_BYTES are read from
Arena::new, which runs as tls_hot::fill's first provider. A HotKey
there re-enters fill — which writes temp_roots last, deliberately, so a
re-entrant reader cannot mistake a half-filled cache for a ready one — and
re-runs ARENA's initializer without bound.

gc::tests::tls_fill_reentrancy is the standing guard, and it is
sabotage-proved: moving ARENA_TOTAL_BYTES alone into the neighbouring hot
block aborts it with thread '<unknown>' has overflowed its stack / fatal runtime error: stack overflow in a release build. It is a crash at thread
start, not a slow path, and it is the first recorded instance of the rule.

Tests

  • gc::tests::trigger_path_tls drives gc_check_trigger on a fresh thread and
    asserts every trigger-path declaration owns a hot slot. It can fail and did:
    the first run rejected GC_DEFERRED_REQUEST with index 4294967295
    correctly, since defer_gc_request reads it only while a root lock is held,
    so it is not a fast-path read. The list is what the fast path reads.
    Reverting any listed declaration to a raw thread_local! removes
    slot_index and breaks the build at that declaration's own name.
  • gc::tests::tls_fill_reentrancy, sabotage-proved above.
  • check_thread_locals.py --self-test gained direction 7, sabotage-proved:
    restoring the block count makes that assertion, and only that one, fail.
    Directions 1–6 still pass, so the change did not weaken any of them.
  • cargo test -p perry-runtime --release -- --test-threads=1 gc:: arena:::
    1,134 passed, 0 failed.
  • gc_runtime_root_holders.py, check_gc_doc_claims.py,
    check_thread_locals.py, check_locale_independent_io.py,
    gc_ratchet validate --scope structural, tests/test_gc_ratchet.py: all
    green, self-tests included. PASS1_MARKED's non-moving-window pin is
    re-audited and re-pinned for the gc/policy.rs change (the conversion alters
    no value, predicate or branch; the one new behaviour, a mutex plus a global
    allocation on a declaration's first read, relocates nothing and runs no JS,
    so the window's contract holds even if a first read landed inside it).
  • cargo clippy -p perry-runtime --lib --tests: no finding in any file this PR
    touches.

https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

Summary by CodeRabbit

  • Performance

    • Improved garbage collection, allocation, arena, and write-barrier hot paths by optimizing thread-local access.
    • Reduced overhead when triggering garbage collection and processing memory-management operations.
  • Reliability

    • Added safeguards against thread initialization recursion and stack overflows during early runtime startup.
    • Added coverage to ensure optimized thread-local access remains active on critical collection paths.
  • Maintenance

    • Strengthened checks that prevent performance-sensitive thread-local declarations from unintentionally regressing.

`gc_check_trigger` runs on every `gc_malloc`, and `gc_budgeted_due_trigger`
resolved eleven raw `thread_local!` declarations one `_tlv_get_addr` call at
a time. Measured with `sample` on the compiled claude-code TUI streaming a
3300-char reply (14,578 active main-thread samples, callers resolved by an
explicit ancestor walk): `_tlv_get_addr` was 380 main-thread leaf samples,
71 of them with `gc_budgeted_due_trigger` as the immediate caller, 36 in
`old_page_account_dirty_slots`, 31 in `scan_dirty_object_slots`, 27 in
`gc_malloc_header_is_tracked`.

Sixty-seven declarations move to `crate::perry_thread_local!`.

Why they were still cold is a measurement bug in the gate, not an oversight:
`scripts/check_thread_locals.py` ratchets on raw `thread_local!` BLOCKS per
file, and a block holds any number of declarations — so `gc/policy.rs`
counted as 6 while declaring 28, and adding a `static` to a recorded block
passed silently. In the same unit as the hot side, main was 318 hot against
339 cold declarations. The gate now ratchets on declarations (385/272) and
`--self-test` gained the direction that catches it.

`ARENA_TOTAL_BYTES`, `BLOCK_POOL` and `BLOCK_POOL_BYTES` stay raw and say so:
they are read from `Arena::new`, which runs as `tls_hot::fill`'s first
provider, so a `HotKey` there re-enters `fill` — which has not yet written
the `temp_roots` field it gates on — and re-runs `ARENA`'s initializer
without bound.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 5, 2026
@coderabbitai

coderabbitai Bot commented Sep 5, 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: Team

Run ID: 1b453e5a-c028-4687-8b73-591191810c2a

📥 Commits

Reviewing files that changed from the base of the PR and between d36a1af and 4a58e85.

📒 Files selected for processing (16)
  • changelog.d/9827-gc-trigger-path-hot-tls.md
  • 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/mod.rs
  • crates/perry-runtime/src/gc/malloc.rs
  • crates/perry-runtime/src/gc/old_free.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tenuring.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/tls_fill_reentrancy.rs
  • crates/perry-runtime/src/gc/tests/trigger_path_tls.rs
  • crates/perry-runtime/src/gc/trace.rs
  • scripts/check_thread_locals.py
  • scripts/gc_runtime_root_holders.json
  • scripts/thread_local_cold_allowlist.json

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


📝 Walkthrough

Walkthrough

The change migrates GC and arena thread-local declarations to hot TLS, preserves raw declarations required for reentrancy or HotTls access, adds runtime coverage, and changes the cold-TLS checker to count declarations.

Changes

GC hot TLS migration

Layer / File(s) Summary
Migrate GC and arena TLS declarations
crates/perry-runtime/src/arena/..., crates/perry-runtime/src/gc/barrier/..., crates/perry-runtime/src/gc/malloc.rs, crates/perry-runtime/src/gc/old_free.rs, crates/perry-runtime/src/gc/tenuring.rs, crates/perry-runtime/src/gc/trace.rs
Supported declarations now use crate::perry_thread_local!. Raw declarations remain for TLS fill reentrancy and named HotTls fields.
Expose trigger-path hot slots
crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/arena/..., crates/perry-runtime/src/gc/malloc.rs, crates/perry-runtime/src/gc/old_free.rs
GC trigger state uses hot TLS. Test-only helpers expose slot indices for trigger-path validation.
Validate hot-cache resolution and fill safety
crates/perry-runtime/src/gc/tests/*
Fresh-thread tests verify hot-slot publication, trigger-path resolution, and arena initialization during TLS filling.
Update declaration accounting and records
scripts/check_thread_locals.py, scripts/thread_local_cold_allowlist.json, scripts/gc_runtime_root_holders.json, changelog.d/9827-gc-trigger-path-hot-tls.md
The checker counts raw declarations, tests additions inside existing blocks, and updates allowlist, source-pin, and changelog records.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 4a58e

The hot-TLS migration and declaration ratchet have focused regression coverage, with no concrete current-head issue requiring resolution before merge.

Sequence Diagram(s)

sequenceDiagram
  participant GCTrigger as gc_check_trigger
  participant Policy as trigger_path_hot_slot_indices
  participant HotTLS as perry_thread_local!
  participant Slots as published_slots
  GCTrigger->>Policy: read trigger-path TLS
  Policy->>HotTLS: resolve declared slots
  HotTLS->>Slots: publish slot addresses
  Slots-->>GCTrigger: provide cached TLS access
Loading

Possibly related PRs

  • PerryTS/perry#7758: Establishes the perry_thread_local! and raw thread_local! policy extended by this migration.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 25 functions across 13 files. (3 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies both primary changes: routing the GC trigger path through hot TLS and correcting declaration-based policy counting.
Description check ✅ Passed The description is detailed and directly covers the changes, rationale, measurements, related issue, tests, and known limitations. It does not use the template headings or include the checklist, but t…
Full details: Docstring Coverage

Explanation

Docstring coverage is 64.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 25 functions across 13 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug proggeramlug changed the title perf(gc): route the trigger path and dirty-page barrier through hot TLS, and make the policy gate count declarations perf(gc): route the trigger path through hot TLS — and fix the gate that counts blocks where the cost is per declaration Sep 5, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9867. The gc_runtime_root_holders.json conflicts were resolved with a structural JSON merge (union by (file,name), pins recomputed from the tree) rather than a text union. Thanks!

proggeramlug pushed a commit that referenced this pull request Sep 6, 2026
…name

Two build breaks the merge produced:

- `old_gen_in_use_bytes_slot_index` was re-exported twice from
  `arena/mod.rs` (E0252) after #9853 and #9827 both added the line.
- `VisitedLevels` gained a lifetime parameter when its levels became
  RuntimeHandles, and an associated `Self::INLINE` is not permitted in
  the array length of a generic struct, so it became the free const
  `VISITED_INLINE`. enumeration_tests.rs still named the old path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant