perf(gc): route the trigger path through hot TLS — and fix the gate that counts blocks where the cost is per declaration - #9827
Conversation
`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
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (16)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughThe change migrates GC and arena thread-local declarations to hot TLS, preserves raw declarations required for reentrancy or ChangesGC hot TLS migration
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to 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
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation 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.)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Landed on |
…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.
What
gc_check_triggerruns on everygc_malloc, andgc_budgeted_due_triggerresolved eleven raw
thread_local!declarations one_tlv_get_addrcall at atime.
crates/perry-runtime/src/tls_hot.rshas existed to abolish exactly thissince #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.rsandarena/page_meta.rsmove tocrate::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_addris paid per declaration read.scripts/check_thread_locals.pyratchets on the number of raw
thread_local!blocks per file, andthread_local! { … }holds any number of declarations. A ratchet denominatedin blocks cannot see the quantity it exists to bound.
Three consequences, all reproduced on
mainatd36a1af0c:staticadded to an already-recorded block passes the gate insilence. 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.
gc/policy.rswasrecorded as 6 while declaring 28.
arena/block.rswent from 11 colddeclarations to 0 in this PR with its recorded block count unchanged.
Counted in the hot side's unit — the hot side was always counted in
declarations —
mainwas 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
_commentstates that adding astaticto a listed file fails"whether or not it opens a new block".
--self-testgained a seventh direction for case (1): astaticadded to analready-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, offlinemock API, sandbox
ov. Baselinecc_relink/cc_main_0905(maind36a1af0c).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 resolvedby an explicit ancestor walk (
secret-tests/cc-perf-campaign/tlv_an.py), notnearest-symbol labels.
_tlv_get_addrleaf samplesgc_budgeted_due_triggergc_check_triggeranywherePer-caller, all to zero:
old_page_account_dirty_slots36 → 0,gc_malloc_header_is_tracked27 → 0,mark_addr22 → 0,dead_owner16 → 0,mark_mutable_root_bits15 → 0,old_free_take_exact9 → 0,old_page_account_swept_object10 → 0._tlv_get_addrleaves the top-threeleaf symbols entirely.
The schedule is flat — stated as a prediction, not found as a consolation
PERRY_GC_DIAG=1, 400-char reply, both arms:[gc-copy-minor]lines[gc-step](budgeted full-cycle steps)[gc-restore-coverage][gc-scanner-profile]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 sessiontimed_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_addr2.61 % → 0.80 % ofactive 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_POOLandBLOCK_POOL_BYTESare read fromArena::new, which runs astls_hot::fill's first provider. AHotKeythere re-enters
fill— which writestemp_rootslast, deliberately, so are-entrant reader cannot mistake a half-filled cache for a ready one — and
re-runs
ARENA's initializer without bound.gc::tests::tls_fill_reentrancyis the standing guard, and it issabotage-proved: moving
ARENA_TOTAL_BYTESalone into the neighbouring hotblock aborts it with
thread '<unknown>' has overflowed its stack / fatal runtime error: stack overflowin a release build. It is a crash at threadstart, not a slow path, and it is the first recorded instance of the rule.
Tests
gc::tests::trigger_path_tlsdrivesgc_check_triggeron a fresh thread andasserts every trigger-path declaration owns a hot slot. It can fail and did:
the first run rejected
GC_DEFERRED_REQUESTwithindex 4294967295—correctly, since
defer_gc_requestreads 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!removesslot_indexand breaks the build at that declaration's own name.gc::tests::tls_fill_reentrancy, sabotage-proved above.check_thread_locals.py --self-testgained 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: allgreen, self-tests included.
PASS1_MARKED's non-moving-window pin isre-audited and re-pinned for the
gc/policy.rschange (the conversion altersno 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 PRtouches.
https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
Summary by CodeRabbit
Performance
Reliability
Maintenance