-
-
Notifications
You must be signed in to change notification settings - Fork 161
perf(gc): replace the 4-way page-class cache with a direct-indexed table — classification misses 18.5% -> 6.0% #9853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
2e99865
perf(gc): route the trigger path and dirty-page barrier through hot TLS
4a58e85
docs(changelog): rename the fragment to its PR number
165aa78
perf(gc): direct-indexed page-class table behind PERRY_GC_PAGE_CLASS_…
93ffee5
docs(changelog): fragment for the page-class table, and sort the re-e…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| **The GC trigger path and the dirty-page barrier stop paying `_tlv_get_addr` | ||
| per read, and the policy gate that let them stop paying it now counts the | ||
| thing it is bounding.** | ||
|
|
||
| `gc_check_trigger` runs on every `gc_malloc`, and its predicate | ||
| (`gc_budgeted_due_trigger`) resolved eleven raw `thread_local!` declarations | ||
| one out-of-line 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 rather than | ||
| nearest-symbol labels): `_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`. `crates/perry-runtime/src/tls_hot.rs` has | ||
| existed to abolish exactly this since #7469; the allocation path's *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!` — same syntax, same | ||
| `.with()` at every call site, the address served from this thread's hot cache | ||
| instead of a libdyld call. | ||
|
|
||
| **Why they were still cold is a measurement bug in the gate, not an oversight | ||
| anyone could have noticed.** `scripts/check_thread_locals.py` ratchets on the | ||
| number of raw `thread_local!` **blocks** per file, while `thread_local! { … }` | ||
| holds any number of declarations — so `gc/policy.rs` counted as **6** while | ||
| declaring **28**, and adding a `static` to an already-recorded block passed | ||
| the gate silently. Counted in the same unit as the hot side, `main` was **318 | ||
| hot declarations against 339 cold ones** — cold was the majority, reported as | ||
| a 2.6:1 minority. The gate now ratchets on declarations (`385 hot / 272 | ||
| cold`), and `--self-test` grew a seventh direction that fails when a `static` | ||
| is added to a recorded block; restoring the block count makes that case, and | ||
| only that case, fail. | ||
|
|
||
| Three declarations stay deliberately raw and say so at their declaration: | ||
| `ARENA_TOTAL_BYTES`, `BLOCK_POOL` and `BLOCK_POOL_BYTES` are read from | ||
| `Arena::new`, which runs as `tls_hot::fill`'s **first** provider, so a | ||
| `HotKey` there re-enters `fill` — which by design has not yet written the | ||
| `temp_roots` field it gates on — and re-runs `ARENA`'s initializer without | ||
| bound. It is a stack overflow at thread start, not a slow path, and it is the | ||
| first documented instance of the rule that a declaration read from inside a | ||
| `fill` provider cannot use the macro. `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 that test with `fatal runtime error: | ||
| stack overflow`. | ||
|
|
||
| `gc::tests::trigger_path_tls` is the runtime half of the gate: it drives | ||
| `gc_check_trigger` on a fresh thread and asserts every trigger-path | ||
| declaration owns a hot slot and that the path publishes slots at all. | ||
| Reverting any one of them to a raw `thread_local!` removes `slot_index` and | ||
| breaks the build at that declaration's own name. It is a test that can fail | ||
| and did: the first run rejected `GC_DEFERRED_REQUEST` with `index 4294967295`, | ||
| correctly — `defer_gc_request` reads it only while a root lock is held, so it | ||
| is not a fast-path read and never claims a slot. The list is what the fast | ||
| path reads, not what the module declares. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| **The page-generation cache becomes a direct-indexed table over the arena's | ||
| 1 MiB address classes, so a classification is a bounds compare and one load | ||
| instead of a four-way probe that missed one call in five.** | ||
|
|
||
| `classify_heap_generation` and `classify_heap_space_in_range` sit under three | ||
| callers with no cheaper predicate of their own. The write barrier's | ||
| `remembered_child_needs_tracking` runs **35,871,391 times per turn** on the | ||
| compiled claude-code TUI and **95.23 %** of those take its cheapest arm — one | ||
| cached classification and a compare — so there is no barrier predicate left to | ||
| fix: what remains after the predicate is already optimal is the classification | ||
| itself. `mark_addr` (233 of 760 `classify*` leaf samples) and the side-table | ||
| prunes pay the same cost. | ||
|
|
||
| The structure in front of the authoritative `PageGenerationMap` was a **4-way | ||
| round-robin set**. Measured with a dedicated counter on a 3300-char streaming | ||
| reply: **440 M lookups per turn at 20.0–21.6 % miss**, with **59.7–61.8 % of | ||
| misses on a key evicted within the last 64 evictions** — capacity, not conflict — | ||
| against a working set of **402–432 registered classes**. `ways_distinct_max` was | ||
| 4, so every way was already in use and the shortfall is ~120x. | ||
|
|
||
| **Widening it was not an option, and the reason is on the record.** #7469 | ||
| measured 16 ways as an **8.6 % regression** on the same row (0/7 pairs) for 1.5 % | ||
| fewer misses, and five further associativity changes measured flat. The rule | ||
| those produced — *associativity pays only when a miss is expensive* — says that a | ||
| miss which is just a hash lookup wants the cache to become **unnecessary**, not | ||
| larger. | ||
|
|
||
| It can be. The registered classes occupy a span of **1,018–1,021 classes at | ||
| ~40 % density**, so a table over that span holds every one of them in **160 KB** | ||
| and answers with one bounds compare and one load. `PageGenerationMap` stays | ||
| authoritative and every miss falls through to it exactly as before; the change is | ||
| confined to `PageGenerationCacheSet` and its two callers. | ||
|
|
||
| Four things the measurement did not settle, each handled explicitly and each | ||
| pinned by a test that fails when its guard is removed — a wrong answer here is a | ||
| misclassified pointer, so none of them is left to inference: | ||
|
|
||
| * **The base moves per process** (`0x43daa2` vs `0x57e3c2` on two runs — ASLR). | ||
| It is taken from the first insert, never compiled in. | ||
| * **The span can grow** (1,018 → 1,021 across two runs of one binary). An insert | ||
| outside the table rebases it, up to a 16,384-class cap; past the cap the key is | ||
| left uncached and falls through to the map rather than being mis-indexed. | ||
| * **The sizing is not obvious.** With base `first_key - S` and a table of `N`, | ||
| the span covered is `min(S + 1, N - S)`, maximised at `S = N / 2`. The natural | ||
| pairing `N = 4096, S = 1024` covers **1,025** classes — four above the measured | ||
| span — while `S = N / 2` covers **2,048** for the same memory. A `const` assert | ||
| now fails the build for any pairing covering less than twice the measured span. | ||
| * **A key match is not an address match.** A class can hold more than one range, | ||
| so a hit still requires `range.contains(addr)`. | ||
|
|
||
| Invalidation is an epoch bump: O(1), and the same "clear everything" contract the | ||
| 4-way set met by being reset wholesale. That contract matters more here, because | ||
| the table holds ~2,000 entries where the set held 4 — a missing invalidation the | ||
| old structure survived by luck would be a live misclassification — so all three | ||
| `PageGenerationMap` mutation sites were enumerated and each ends with an | ||
| unconditional `invalidate_generation_cache()`. | ||
|
|
||
| The arm is a plain `u8` field in the set's first cache line rather than the env | ||
| `OnceLock`: this path runs 440 M times per turn, and an acquire load on each | ||
| would have been charged to both arms of the A/B — hiding it in the comparison | ||
| that was meant to isolate it — while still being paid against main. | ||
| `PERRY_GC_PAGE_CLASS_TABLE=0` restores the 4-way set in the same binary, which is | ||
| how the numbers above and below were taken. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the quantitative claims in the release note.
The stated inputs do not support two values:
ways_distinct_max = 4gives 100.5–108×, not~120x.N = 4096, S = 1024covers 1,025 classes, while twice the stated 1,018–1,021 span is 2,036–2,042. The assertion description therefore contradicts the preceding configuration.Update the measurements or describe the actual assertion invariant.
Based on learnings: “For PerryTS/perry changelog fragments in
changelog.d/, describe the final shipped behavior as one coherent release-note entry. Do not include separate development-slice narratives that may contradict one another when the release notes are assembled.”Also applies to: 47-47
🤖 Prompt for AI Agents
Source: Learnings