-
-
Notifications
You must be signed in to change notification settings - Fork 161
perf(gc): decode a visited word once in the copying minor (#10362) #10491
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
2 commits
Select commit
Hold shift + click to select a range
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,35 @@ | ||
| Decode each word the copying minor visits once. A raw (untagged) word was | ||
| classified twice: `CopyingPointerSet::decode_bits` classified it only to | ||
| validate it, and `mark_addr` classified it again. Every traced shaped object | ||
| visits its shape record's `keys` word, a raw address, so that was a second | ||
| page-table probe and header read per traced object. The slot visit's | ||
| remembering arm then re-decoded the slot it had just decoded. The validating | ||
| classification is now the one the mark uses (the memo is still consulted after | ||
| it, as before), and the remembering arm reuses the child the visit decoded; | ||
| only a raw word that moved is validated again, which is all the re-decode could | ||
| still reject. | ||
|
|
||
| Two codegen facts are load-bearing and pinned by comment. The decode is | ||
| `#[inline(always)]`: out of line, its call frame and the by-memory return of | ||
| its result cost as much as the classification it saves (the first cut measured | ||
| flat to +1.05%). And `barrier_parent_needs_remembering` is asked before the | ||
| visit rather than after. It reads only the parent and the slot's address, so | ||
| the order does not change the answer, but asked after, the optimizer | ||
| duplicated the call into both decode arms and stopped inlining it, which gave | ||
| back a third of the win on gc3 (-1.20% instead of -1.80%) and more than a third | ||
| on w20000 (-0.84% instead of -1.44%). | ||
|
|
||
| Measured on six GC fixtures, instructions:u min-of-5: gc3 -1.83%, w5000 -1.77%, | ||
| w20000 -1.52%, oldyoung -1.46%, w1000 -0.84%, alloc flat (-951 instructions). | ||
| Exact instruction counts under callgrind agree: gc3 -1.79%, with | ||
| `classify_arena` calls down from 6.09M to 4.20M. On the pointer-slot control | ||
| (60k records whose K fields all point at one shared object, against the same | ||
| records holding doubles) the per-slot term falls from 379.2 to 349.6 | ||
| instructions at K=16, counted exactly under callgrind: a memo hit no longer pays a call to | ||
| `mark_addr`, and the re-decode's classification is gone. | ||
|
|
||
| The page-generation cache was read before any of this was attempted. It runs | ||
| the direct-mapped table arm with a 93.4-97.3% hit rate, and at most 0.02% of | ||
| lookups are capacity misses. Nearly every miss is an address in no registered | ||
| block: the shape record's `keys` slot, which lives outside the heap. So the | ||
| cache's size was not the problem, and nothing here changes it. |
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
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,141 @@ | ||
| //! The copying minor decodes each visited word ONCE (`visit_value_bits_child`): | ||
| //! a raw word's validating classification is the one the mark uses, and the | ||
| //! remembering arm reuses the child the visit decoded instead of re-decoding | ||
| //! the slot. Both halves are pinned by collections and what they leave in the | ||
| //! heap, and each has a sabotaged twin that must fail. | ||
|
|
||
| use super::super::*; | ||
| use super::support::*; | ||
| use crate::gc::copying_parent_facts::copy_decode_sabotage::{Guard, CHILD, RAW_MARK}; | ||
|
|
||
| fn string_bytes(addr: usize) -> Vec<u8> { | ||
| unsafe { | ||
| let s = addr as *const crate::StringHeader; | ||
| let data = (s as *const u8).add(std::mem::size_of::<crate::StringHeader>()); | ||
| std::slice::from_raw_parts(data, (*s).byte_len as usize).to_vec() | ||
| } | ||
| } | ||
|
|
||
| /// A young string reachable ONLY through a RAW (untagged) word in a rooted | ||
| /// young object. Returns whether the minor evacuated it through that word. | ||
| fn raw_child_evacuated(sabotaged: bool) -> bool { | ||
| std::thread::spawn(move || { | ||
| let _guard = CopyingNurseryTestGuard::new(1); | ||
| let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); | ||
| let _scan = ConservativeScanDisabledGuard::new(); | ||
| let _roots = ShadowAndGlobalRootResetGuard; | ||
| let (parent, fields) = unsafe { alloc_nursery_test_object(1) }; | ||
| let child = young_leaf(); | ||
| let expected = string_bytes(child); | ||
| unsafe { *fields = child as u64 }; | ||
| js_shadow_slot_set(0, ptr_bits(parent as usize)); | ||
| assert!( | ||
| crate::arena::pointer_in_nursery(child), | ||
| "premise: the child must be young, or there is nothing to evacuate" | ||
| ); | ||
| { | ||
| let _sabotage = sabotaged.then(|| Guard::arm(RAW_MARK)); | ||
| let _ = gc_collect_minor(); | ||
| } | ||
| let parent_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; | ||
| assert_ne!( | ||
| parent_after, parent as usize, | ||
| "premise: the rooted parent moved" | ||
| ); | ||
| let word = unsafe { | ||
| *((parent_after as *const u8).add(std::mem::size_of::<crate::object::ObjectHeader>()) | ||
| as *const u64) | ||
| }; | ||
| // Checked before any read through `word`: a stale word names from-space. | ||
| word != child as u64 && string_bytes(word as usize) == expected | ||
| }) | ||
| .join() | ||
| .expect("raw-word decode test thread must not panic") | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_raw_word_is_marked_through_its_validating_classification() { | ||
| assert!( | ||
| raw_child_evacuated(false), | ||
| "the young child behind a raw word must be evacuated and the word rewritten" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn sabotaged_raw_mark_leaves_the_raw_word_stale() { | ||
| assert!( | ||
| !raw_child_evacuated(true), | ||
| "with the validated raw word dropped instead of marked, the child is not evacuated" | ||
| ); | ||
| } | ||
|
|
||
| /// An OLD parent whose NaN-boxed slot holds a young child, handed to the minor | ||
| /// through the write barrier, then two minors: the second finds the edge only | ||
| /// if the first re-remembered it from the child its visit decoded. `Err` is | ||
| /// the collection thread's panic message. | ||
| fn old_edge_across_two_minors(sabotaged: bool) -> Result<bool, String> { | ||
| std::thread::spawn(move || { | ||
| let _guard = CopyingNurseryTestGuard::new(1); | ||
| let _tenuring = crate::gc::tenuring::set_survivals_for_test( | ||
| crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX, | ||
| ); | ||
| let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); | ||
| let _scan = ConservativeScanDisabledGuard::new(); | ||
| let _roots = ShadowAndGlobalRootResetGuard; | ||
| let (parent, fields) = unsafe { alloc_old_test_object(1) }; | ||
| let child = young_leaf(); | ||
| let expected = string_bytes(child); | ||
| unsafe { *fields = ptr_bits(child) }; | ||
| js_write_barrier_slot(ptr_bits(parent as usize), fields as u64, ptr_bits(child)); | ||
| assert!( | ||
| crate::arena::pointer_in_old_gen(parent as usize) | ||
| && crate::arena::pointer_in_nursery(child), | ||
| "premise: an old parent and a young child" | ||
| ); | ||
| let read = || unsafe { (*fields & POINTER_MASK) as usize }; | ||
| { | ||
| let _sabotage = sabotaged.then(|| Guard::arm(CHILD)); | ||
| let _ = gc_collect_minor(); | ||
| } | ||
| let first = read(); | ||
| assert!( | ||
| first != child && crate::arena::pointer_in_nursery(first), | ||
| "premise: the first minor copied the child within the nursery" | ||
| ); | ||
| let _ = gc_collect_minor(); | ||
| let second = read(); | ||
| second != first && string_bytes(second) == expected | ||
| }) | ||
| .join() | ||
| .map_err(|payload| { | ||
| payload | ||
| .downcast_ref::<String>() | ||
| .cloned() | ||
| .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) | ||
| .unwrap_or_default() | ||
| }) | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_old_parents_edge_is_remembered_from_the_child_the_visit_decoded() { | ||
| assert_eq!( | ||
| old_edge_across_two_minors(false), | ||
| Ok(true), | ||
| "the second minor must find and move the child through the remembered edge" | ||
| ); | ||
| } | ||
|
|
||
| /// In a release build `restore_surviving_dirty_coverage` would re-add the page | ||
| /// the arm failed to remember, which is why a forgotten remembered-set entry | ||
| /// is invisible to a survival check alone. In the debug build `cargo test` | ||
| /// runs, the same walk cross-checks the dirty scan's per-slot re-remembering | ||
| /// and refuses the disagreement — that refusal is this twin's observable. | ||
| #[test] | ||
| fn sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check() { | ||
| let outcome = old_edge_across_two_minors(true); | ||
| assert!( | ||
| matches!(&outcome, Err(message) if message.contains("restore_surviving_dirty_coverage")), | ||
| "with the decoded child forgotten, the coverage walk must report the \ | ||
| unremembered page; got {outcome:?}" | ||
| ); | ||
| } | ||
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50370
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50370
🤖 get_repo_knowledge executed:
get_repo_knowledge PerryTS/perry /tmp/coderabbit-repo-knowledge/perryts-perry-d4a878bc/learningsLength of output: 20213
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
Add a two-minor test for a raw old-to-young edge.
alloc_old_test_object(1)creates a traced inline slot, andvisit_value_bits_childaccepts untagged heap addresses. The existing raw tests use a young parent, so they do not enter old-parent remembering. The existing old-parent test storesptr_bits(child), so it uses the tagged branch. A regression inrevalidate_moved_raworremembered_child_needs_trackingcan therefore leave a moved raw child unremembered and make the second minor miss it.🤖 Prompt for AI Agents