Skip to content

perf(gc): stop the runtime write barrier re-deriving its parent address (#7187 increment 1) - #7193

Merged
proggeramlug merged 2 commits into
mainfrom
perf/7187-barrier-classify
Aug 1, 2026
Merged

perf(gc): stop the runtime write barrier re-deriving its parent address (#7187 increment 1)#7193
proggeramlug merged 2 commits into
mainfrom
perf/7187-barrier-classify

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

classify_heap_generation is 19.03% of benchmarks/app-patterns/kernels/batch.ts — 657M instructions, the largest single symbol in that program, 2.3x the next one, called from the write barrier with zero collections running (#7170's ranked profile; full account and the design for the rest on #7187).

Reading the path, four page-map classifications happen per barriered element store:

# site operand 1 MiB bucket
1 decode_heap_addr(parent) parent old
2 barrier_parent_needs_remembering parent old
3 remembered_child_needs_tracking child nursery
4 remember_old_to_young_slot slot old

The single-entry PAGE_GENERATION_CACHE therefore thrashes by construction — parent/slot and child alternate between two buckets, so two of four calls take the full hashmap path on every store, forever.

This PR takes #1, which answers a question the caller had already answered, and makes the remaining lookups cheaper. It is the first of the increments designed on #7187; lazy barrier arming (which removes #2#4 entirely until the first collection) stays a design pending its own dispatch.

1. Stop re-deriving the parent address

runtime_write_barrier_slot receives parent_addr: usize — a decoded GC user pointer — and handed it to js_write_barrier_slot as a bare u64, so decode_heap_addr fell into its "possible raw pointer" arm and paid a full classify_heap_generation to recover it, immediately before barrier_parent_needs_remembering classified the same address again.

The three runtime entry points now share write_barrier_slot_decoded, which keeps the parent a usize throughout. The never-skippable half — child decode plus incremental_mark_barrier_value — is factored into barrier_child_prologue so the #6011 primitive-store fast path (child_addr == 0) stays the cheapest exit and is provably shared by both routes.

Outcome-preserving, including the one case that changes counter: a malloc-GC parent used to exit at NonPointerParentSkips (classification returned Unknown, so the decode returned 0) and now exits at ParentNotOldSkips. Different reason, same remembered-set effect — and the new reason is the one that is actually true.

2. A latent dereference the round-trip was hiding

This is the part worth reviewing. malloc_gc_parent_addr dereferences parent_addr - GC_HEADER_SIZE behind a bare < GC_HEADER_SIZE + 0x1000 floor, which admits every handle-band id and every out-of-range garbage word. It was safe only because its callers happened to filter first — including by accident: runtime_write_barrier_external_slot NaN-boxed its parent, and an address with high bits set ORs into something that is no longer POINTER_TAG, so the decode rejected it before the deref.

closure/dynamic_props.rs parks props under exactly such non-address owner keys and depends on that accident — its own unit test uses 0xC10C_AB1E_0000_1803. Removing the round-trip without noticing SIGSEGVs there, which is how this was found.

Both filters are now one explicit predicate, barrier_parent_addr_is_dereferenceable — the canonical addr_class::is_plausible_heap_addr (the repo's answer to this whole hazard class) plus the 8-alignment addr_class does not cover — applied in write_barrier_slot_decoded and inside malloc_gc_parent_addr itself, so the function that dereferences carries its own guard instead of trusting callers. That is a strict improvement on the pre-existing state: the guard now also covers the NaN-boxed callers, where a plausible-but-unaligned parent previously reached the header read.

3. The page-generation map's hasher

PAGE_GENERATIONS carried a bespoke identity hasher whose write_usize stored the key verbatim. HashMap is hashbrown: the bucket index comes from the hash's low bits, but the SIMD control byte is hash >> 57. Keys are addr >> GENERATION_CLASS_SHIFT — around 2^26 for a real heap address — so the control byte was zero for every entry in the table. Every group probe then matches every occupied slot, and each match costs a real key comparison (a scattered load into a bucket) before the right one is found — on a lookup the write barrier performs several times per heap store.

Not an argument — measured by the new regression test with the old hasher reinstated:

hashbrown control byte must vary across generation class keys,
got 1 distinct values from 64 consecutive buckets (an identity hasher yields 1)

The map now uses fast_hash::PtrHasher, which is the project's existing answer to exactly this (see its module doc, and the mix(h) = h ^ (h >> 32) step whose comment records a 455 ms -> 830 ms regression from omitting it). PAGE_GENERATIONS is only ever point-queried — get/get_mut/insert/remove; the first_key..=last_key loops walk key ranges, not the map — so iteration order is not observable and there is no determinism exposure.

Tests, and what each one is red for

gc/tests/barrier_decoded_parent.rs (new sibling module — the additions pushed barrier.rs past the 2000-line cap) and arena/page_meta.rs's test module. Every sabotage below was run:

sabotage result
identity hasher reinstated control_byte_is_spread_across_generation_class_keys FAILS: got 1 distinct values
plausibility guard removed from write_barrier_slot_decoded + malloc_gc_parent_addr runtime_barrier_entry_points_reject_implausible_parents_without_dereferencing SIGSEGV, and so does the pre-existing closure::dynamic_props::tests_1802::dyn_prop_scanner_visits_values_without_holding_props_lock
parent dropped on the decoded route (write_barrier_slot_decoded(0, …)) 4 tests FAIL, incl. "old→young store through the decoded entry point must dirty the slot page"

bucket_index_is_spread_across_generation_class_keys passes under the identity hasher too — deliberately. It is the guard against over-correcting into a hasher that fixes the control byte and wrecks the bucket index, not a duplicate of the test above it.

runtime_write_barrier_slot_matches_nanboxed_entry_point walks every parent generation (old / nursery / malloc) x every child kind (young / old / malloc / primitive) and asserts the decoded route leaves the collector byte-identical remembered-set state to the NaN-boxed one.

Verification

gate result
cargo test -p perry-runtime --lib, default scan mode 1616 passed, 0 failed (single-threaded)
same, PERRY_CONSERVATIVE_STACK_SCAN=off 1616 passed, 0 failed
parallel (default cargo test) 3–6 order-dependent failures, set varies per run; origin/main produces 6 from the same family in the same worktree — gc::tests::teardown::map_set_*, global_this_webassembly, native_module_stream, root_words. Pre-existing, and #7161 recorded the same set.
scripts/gc_repsel_matrix.sh --arms all --pressure 8 see below
benchmarks/gc_ratchet gated + ungated see below
cargo fmt --all -- --check clean
scripts/check_file_size.sh red at base for 9 files; zero new offenders on this branch (verified by diffing the offender lists against an origin/main worktree)
scripts/addr_class_inventory.py byte-identical output to origin/main
oracle Node 26.5.1 (.node-version)

Built once, one target dir, --profile release, -p perry -p perry-runtime-static -p perry-stdlib-static.

Boundary with concurrent GC work

#7154 is closed; jdalton's live work is #7192 (fix/7154-root-store-dominance), which touches perry-codegen/src/expr/*, lower_call/new.rs, a script and a gap test — zero file overlap with this PR, and neither touches gc/verify.rs. Checked same-day.

Not measured

Refs #7187, #7170, #5094.

Summary by CodeRabbit

  • Performance

    • Improved garbage-collection write-barrier efficiency by reducing redundant parent-address processing.
    • Optimized page-generation hash-table lookups.
  • Reliability

    • Added safety validation for invalid or unaligned parent addresses before processing.
    • Preserved remembered-set behavior across supported write-barrier paths.
  • Tests

    • Added regression coverage for decoded parent addresses, generation combinations, memory safety checks, and collector state consistency.

Ralph Küpper added 2 commits August 1, 2026 17:45
`classify_heap_generation` is 19.03% of `benchmarks/app-patterns/kernels/batch.ts`
(657M instructions, #7170) — the largest symbol in that program, called from
the write barrier with zero collections running. Four page-map classifications
happen per barriered element store; this removes the one that answers a
question the caller had already answered, and makes the rest cheaper.

1. `runtime_write_barrier_slot` receives `parent_addr: usize` and handed it to
   `js_write_barrier_slot` as a bare `u64`, so `decode_heap_addr` paid a full
   `classify_heap_generation` to recover it immediately before
   `barrier_parent_needs_remembering` classified the same address again. The
   three runtime entry points now share `write_barrier_slot_decoded`.

2. That round-trip was also FILTERING. `malloc_gc_parent_addr` dereferences
   `parent - GC_HEADER_SIZE` behind a bare `< GC_HEADER_SIZE + 0x1000` floor,
   safe only because callers happened to filter first — including by accident:
   NaN-boxing a high-bit address corrupts the tag, so the decode rejected it,
   and `closure/dynamic_props.rs` (which parks props under non-address owner
   keys) depended on that. Both filters are now one explicit predicate,
   `barrier_parent_addr_is_dereferenceable`, applied in the entry point AND
   inside `malloc_gc_parent_addr` itself.

3. `PAGE_GENERATIONS` carried an identity hasher. hashbrown takes its SIMD
   control byte from `hash >> 57`, and the keys are `addr >> 20` — so the
   control byte was zero for every entry and every group probe matched every
   occupied slot. Measured with the old hasher reinstated: 1 distinct control
   byte across 64 buckets. Now `fast_hash::PtrHasher`.

Refs #7187, #7170, #5094

Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds decoded-parent write-barrier paths, shared parent-address validation, and regression tests. It also replaces the page-generation identity hasher with fast_hash::PtrHashMap and adds distribution and lookup tests.

Changes

Arena generation-map hashing

Layer / File(s) Summary
Shared generation-map hashing
crates/perry-runtime/src/arena/*
PageGenerationMap now uses PtrHashMap and new_ptr_hash_map(). Tests cover hash distribution and point-query correctness. Unused hasher imports were removed.

Decoded-parent write barrier

Layer / File(s) Summary
Decoded-parent barrier flow
crates/perry-runtime/src/gc/barrier.rs, changelog.d/7193-barrier-parent-classify.md
The barrier separates child processing from parent processing. Runtime callers pass decoded parent addresses directly. Parent validation now checks plausibility and alignment before classification or dereference.
Barrier regression coverage
crates/perry-runtime/src/gc/tests/*
Tests compare decoded and NaN-boxed entry points, verify remembered-set updates, validate malloc-parent classification, and reject invalid parent addresses safely.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: andrewtdiz

Sequence Diagram(s)

sequenceDiagram
  participant RuntimeCaller
  participant barrier_child_prologue
  participant write_barrier_decoded_parent
  participant RememberedSet
  RuntimeCaller->>barrier_child_prologue: decode child and mark incrementally
  barrier_child_prologue->>write_barrier_decoded_parent: decoded parent and child
  write_barrier_decoded_parent->>write_barrier_decoded_parent: validate and classify parent
  write_barrier_decoded_parent->>RememberedSet: record old-to-young edge
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary optimization: preventing runtime write barriers from re-deriving parent addresses.
Description check ✅ Passed The description is detailed and covers the changes, rationale, related issues, verification results, limitations, and regression tests.
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/7187-barrier-classify

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

Copy link
Copy Markdown
Contributor Author

Verification log

Everything below was run in one worktree, one target dir, --profile release, -p perry -p perry-runtime-static -p perry-stdlib-static, oracle Node 26.5.1.

scripts/gc_repsel_matrix.sh --arms all --pressure 8

summary: PASS=356 UNVER=242 XFAIL=1 FAIL=10
byte-exact vs node 26.5.1: 598/609 cells

All 10 FAILs are one test, test_gap_repsel_p4a3_ptr_numarray, on the ten requires=move arms — and they are pre-existing. A/B in the same worktree and the same target dir, with only the three production files reverted to origin/main and rebuilt with an identical package set:

FAIL count arms
origin/main 10 evac_minor force_evac force_verify rep_i32_off rep_int_valued_off rep_ptr_numarray_off rep_ptr_shape_off rep_spec_abi_off rep_str_off rep_str_static_off
this branch 10 identical set

Same failure signature on both (output-mismatch cycles=2 evacuated=0 scavenged=6514). This is a requires=move arm hitting evacuated=0 scavenged=…, i.e. the copying-minor path — untriaged today, and it belongs to whoever owns #7016 rather than to this PR. It is not in gc_repsel_triage.txt; I have deliberately not added an entry, since triaging a red cell to make a table green is what that file's header forbids.

Arm liveness — this is a write-barrier change, so it matters: every requires=move arm bit on 28/29 corpus files (copy-minor 28/29, moved-objects 28/29). The barrier's remembered-set path was genuinely exercised under relocation, not certified by an inert arm.

benchmarks/gc_ratchet

The pinned baseline is stale: check reports minor_cycles = 0 and heap_used_bytes +5333% on every probe, because the artifact was pinned while the evacuating minor was default-ON and #7161 flipped it default-OFF. That verdict is about #7161, not this PR, so it cannot say anything either way here. (Related: the gc-ratchet workflow's last runs on main are all cancelled — CLAUDE.md's failure mode #3.)

So I ran a direct branch-vs-base A/B instead, plus a base-vs-base run to establish that the metrics are capable of registering a difference:

comparison gated differences (retention + GC accounting, 8 probes x 9 metrics)
base run #1 vs base run #2 (both origin/main) 0
base vs branch 0
base run #2 vs branch 0

Correctness pass on all 8 probes in every run. The base-vs-base row is the point: the gated medians are deterministic run-to-run even on this (loaded) host, so "0 differences" is a measurement rather than an absence of one.

Ungated column. RSS is flat — every probe within ±0.3%, most within 0.05%:

01_nursery_churn          rss  49,774,592 -> 49,643,520  (-0.3%)
03_cross_gen_writes       rss  59,817,984 -> 59,834,368  (+0.0%)
04_dead_after_deep_stack  rss 178,094,080 -> 177,930,240 (-0.1%)
07_array_grow_evacuate    rss 108,478,464 -> 108,478,464 (+0.0%)
08_map_set_sidetables     rss 151,764,992 -> 151,781,376 (+0.0%)

wall_ms came out 3–21% lower on all eight probes, and I am not claiming that: the base measurement ran at load 60.6/34.96/22.29 and the branch at 8.95/11.95/12.35. The timing column here is load-confounded and is not evidence of anything.

Unit tests

cargo test -p perry-runtime --lib (--profile perry-dev; the release test binary did not fit the disk budget alongside the matrix build):

scan mode result
default, --test-threads=1 1616 passed, 0 failed
PERRY_CONSERVATIVE_STACK_SCAN=off, --test-threads=1 1616 passed, 0 failed
default, parallel 3–6 failures, set varies per run — gc::tests::teardown::map_set_*, global_this_webassembly, native_module_stream, object::prop_plan, root_words. origin/main produced 6 from the same family in the same worktree. Pre-existing and order-dependent; #7161 recorded the same set.

Sabotage

arm result
identity hasher reinstated control_byte_is_spread_across_generation_class_keys FAILS: got 1 distinct values from 64 consecutive buckets
plausibility guard removed runtime_barrier_entry_points_reject_implausible_parents_without_dereferencing SIGSEGV, and so does the pre-existing closure::dynamic_props::tests_1802::dyn_prop_scanner_visits_values_without_holding_props_lock
parent dropped on the decoded route 4 tests FAIL, incl. "old→young store through the decoded entry point must dirty the slot page"

Repo gates

gate result
cargo fmt --all -- --check clean
scripts/check_file_size.sh red at base for 9 files; zero new offenders — verified by diffing offender lists against an origin/main worktree. My additions did push gc/tests/barrier.rs to 2099 lines, which is why they now live in gc/tests/barrier_decoded_parent.rs.
scripts/addr_class_inventory.py byte-identical output to origin/main (red at base for child_process/value_util.rs and fs/dirent.rs, neither touched here)
version bump / CHANGELOG.md none, per contributor policy

Boundary

#7154 is closed. jdalton's live work is #7192, entirely in perry-codegen/src/expr/*, lower_call/new.rs, a script and a gap test — zero file overlap, and neither PR touches gc/verify.rs. Checked today.

Deferred

No instruction-count measurement of this change. The 19.03% / 657M figures are #7170's, from the Pi, which belongs to another session; the claims here are static (one classification removed per runtime-path barriered store) plus the hasher's control-byte degeneracy, which is measured by unit test. A perf stat A/B on the Pi is the outstanding evidence, and it would also settle how much of the 19.03% call #1 was — calls #1 and #2 share a bucket, so removing #1 promotes #2 from cache-hit to cache-miss and the net is not derivable from call counts alone.

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

🤖 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/gc/tests/barrier_decoded_parent.rs`:
- Around line 98-135: Track whether any iteration of the parent/child barrier
parity loop produces a non-empty remembered-state fingerprint, using the
existing `remembered_state_fingerprint` result. After both loops complete,
assert that at least one cell was remembered; the old-parent/young-child
combination must satisfy this assertion while preserving the per-cell
fingerprint equality checks.
- Around line 211-253: Update the test cleanup at the end of the
implausible-parent isolation case, immediately after reset_remembered_set(), to
invoke clear_marks() so marks shaded by barrier_child_prologue are removed
before the test exits. Preserve the existing remembered-set reset and ensure
cleanup covers marks outside the arena/malloc walk.
🪄 Autofix (Beta)

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: cff764d8-58c3-4827-b340-4b9efc9ba365

📥 Commits

Reviewing files that changed from the base of the PR and between ed034a6 and d26c2cc.

📒 Files selected for processing (6)
  • changelog.d/7193-barrier-parent-classify.md
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/page_meta.rs
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/tests/barrier_decoded_parent.rs
  • crates/perry-runtime/src/gc/tests/mod.rs

Comment on lines +98 to +135
for (child_label, child_bits) in children {
for parent_label in ["old", "nursery", "malloc"] {
let (parent_addr, slot_addr) = unsafe {
match parent_label {
"old" => {
let (obj, fields) = alloc_old_test_object(1);
(obj as usize, fields as usize)
}
"nursery" => {
let (obj, fields) = alloc_nursery_test_object(1);
(obj as usize, fields as usize)
}
_ => {
let fields = (malloc_parent as *mut u8)
.add(std::mem::size_of::<crate::object::ObjectHeader>());
(malloc_parent as usize, fields as usize)
}
}
};
unsafe {
std::ptr::write(slot_addr as *mut u64, child_bits);
}

reset_remembered_set();
js_write_barrier_slot(ptr_bits(parent_addr), slot_addr as u64, child_bits);
let nanboxed = remembered_state_fingerprint();

reset_remembered_set();
runtime_write_barrier_slot(parent_addr, slot_addr, child_bits);
let decoded = remembered_state_fingerprint();

assert_eq!(
decoded, nanboxed,
"parent={parent_label} child={child_label}: the decoded entry point must \
leave the collector exactly the remembered set the NaN-boxed one does"
);
}
}

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

Assert that at least one cell actually remembered an edge.

Every assertion in the loop compares two fingerprints. Both sides can be (0, 0, 0). If both entry points regressed to an unconditional early return, all 12 cells would compare (0, 0, 0) against (0, 0, 0) and this test would still pass. The test is advertised as pinning parity across every parent generation and child kind, so it must also prove the barrier ran.

Record whether any cell produced a non-empty remembered set, then assert it after the loops. The old-parent plus young-child cell guarantees one.

💚 Proposed fix to pin a non-vacuous cell
+    let mut any_edge_remembered = false;
     for (child_label, child_bits) in children {
         for parent_label in ["old", "nursery", "malloc"] {
@@
             reset_remembered_set();
             runtime_write_barrier_slot(parent_addr, slot_addr, child_bits);
             let decoded = remembered_state_fingerprint();
+            any_edge_remembered |= decoded != (0, 0, 0);
 
             assert_eq!(
                 decoded, nanboxed,
                 "parent={parent_label} child={child_label}: the decoded entry point must \
                  leave the collector exactly the remembered set the NaN-boxed one does"
             );
         }
     }
+    assert!(
+        any_edge_remembered,
+        "the matrix must contain at least one remembered edge; otherwise every cell \
+         compares two empty remembered sets and the parity assertion is vacuous"
+    );

As per coding guidelines: "GC gates must assert that the behavior under test actually ran, such as requiring copied_objects > 0, rather than passing merely because no exception occurred."

📝 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
for (child_label, child_bits) in children {
for parent_label in ["old", "nursery", "malloc"] {
let (parent_addr, slot_addr) = unsafe {
match parent_label {
"old" => {
let (obj, fields) = alloc_old_test_object(1);
(obj as usize, fields as usize)
}
"nursery" => {
let (obj, fields) = alloc_nursery_test_object(1);
(obj as usize, fields as usize)
}
_ => {
let fields = (malloc_parent as *mut u8)
.add(std::mem::size_of::<crate::object::ObjectHeader>());
(malloc_parent as usize, fields as usize)
}
}
};
unsafe {
std::ptr::write(slot_addr as *mut u64, child_bits);
}
reset_remembered_set();
js_write_barrier_slot(ptr_bits(parent_addr), slot_addr as u64, child_bits);
let nanboxed = remembered_state_fingerprint();
reset_remembered_set();
runtime_write_barrier_slot(parent_addr, slot_addr, child_bits);
let decoded = remembered_state_fingerprint();
assert_eq!(
decoded, nanboxed,
"parent={parent_label} child={child_label}: the decoded entry point must \
leave the collector exactly the remembered set the NaN-boxed one does"
);
}
}
let mut any_edge_remembered = false;
for (child_label, child_bits) in children {
for parent_label in ["old", "nursery", "malloc"] {
let (parent_addr, slot_addr) = unsafe {
match parent_label {
"old" => {
let (obj, fields) = alloc_old_test_object(1);
(obj as usize, fields as usize)
}
"nursery" => {
let (obj, fields) = alloc_nursery_test_object(1);
(obj as usize, fields as usize)
}
_ => {
let fields = (malloc_parent as *mut u8)
.add(std::mem::size_of::<crate::object::ObjectHeader>());
(malloc_parent as usize, fields as usize)
}
}
};
unsafe {
std::ptr::write(slot_addr as *mut u64, child_bits);
}
reset_remembered_set();
js_write_barrier_slot(ptr_bits(parent_addr), slot_addr as u64, child_bits);
let nanboxed = remembered_state_fingerprint();
reset_remembered_set();
runtime_write_barrier_slot(parent_addr, slot_addr, child_bits);
let decoded = remembered_state_fingerprint();
any_edge_remembered |= decoded != (0, 0, 0);
assert_eq!(
decoded, nanboxed,
"parent={parent_label} child={child_label}: the decoded entry point must \
leave the collector exactly the remembered set the NaN-boxed one does"
);
}
}
assert!(
any_edge_remembered,
"the matrix must contain at least one remembered edge; otherwise every cell \
compares two empty remembered sets and the parity assertion is vacuous"
);
🤖 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/gc/tests/barrier_decoded_parent.rs` around lines 98
- 135, Track whether any iteration of the parent/child barrier parity loop
produces a non-empty remembered-state fingerprint, using the existing
`remembered_state_fingerprint` result. After both loops complete, assert that at
least one cell was remembered; the old-parent/young-child combination must
satisfy this assertion while preserving the per-cell fingerprint equality
checks.

Source: Coding guidelines

Comment on lines +211 to +253
let implausible: [(&str, usize); 6] = [
("closure prop owner key", 0xC10C_AB1E_0000_1803),
("common handle band", 0x1234),
("fetch handle band", 0x4_0010),
("proxy id band", 0xF_0008),
("above platform heap range", 0x9000_0000_0000),
("unaligned heap-shaped word", 0x0000_7f31_0000_1003),
];

for (label, parent) in implausible {
runtime_write_barrier_slot(parent, slot_addr, child_bits);
runtime_write_barrier_external_slot(parent, slot_addr, child_bits);
runtime_write_barrier_gc_slot(parent, slot_addr, child_bits);
assert_eq!(
remembered_state_fingerprint(),
(0, 0, 0),
"{label}: an implausible parent must record no edge"
);
}

let counters = take_write_barrier_trace_counters();
if tracing {
assert_eq!(counters.calls, 18);
assert_eq!(
counters.non_pointer_parent_skips, 18,
"every implausible parent must be rejected before any header read"
);
assert_eq!(counters.old_to_young_slow_hits, 0);
}

// The predicate itself, pinned: `malloc_gc_parent_addr` must answer false
// without dereferencing. If the guard is removed this line segfaults
// rather than failing, which is the point of asserting it here as well as
// through the entry points above.
for (label, parent) in implausible {
assert!(
!malloc_gc_parent_addr(parent),
"{label}: must not be classified as a malloc-GC parent"
);
}

reset_remembered_set();
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the plausibility predicate bounds and the GC test isolation guard's Drop.
set -euo pipefail

fd -t f 'addr_class' --exec ast-grep outline {} --items all \;

rg -n -C 20 'fn is_plausible_heap_addr' --type=rust

rg -n -C 25 'impl Drop for GcTestIsolationGuard' --type=rust

rg -n -C 5 'fn clear_marks' --type=rust

Repository: PerryTS/perry

Length of output: 3177


🏁 Script executed:

#!/bin/bash
# Inspect the relevant predicate implementation and test definitions.
set -euo pipefail

sed -n '1,250p' crates/perry-runtime/src/value/addr_class.rs
printf '\n--- test file relevant section ---\n'
sed -n '1,120p' crates/perry-runtime/src/gc/tests/barrier_decoded_parent.rs
printf '\n--- all clear_marks/GcTestIsolationGuard references ---\n'
rg -n -C 8 'fn clear_marks|struct GcTestIsolationGuard|impl Drop for GcTestIsolationGuard|clear_marks\(' crates/perry-runtime/src/gc crates/perry-runtime/src -g '*.rs'

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
# Inspect only the relevant predicate and guard definitions with targeted searches.
set -euo pipefail

printf '%s\n' '--- addr_class relevant lines ---'
sed -n '160,210p' crates/perry-runtime/src/value/addr_class.rs

printf '%s\n' '--- exact guard identifiers ---'
rg -n -C 12 'GcTestIsolationGuard|struct GcTestIsolationGuard|impl Drop for GcTestIsolationGuard|fn clear_marks\(' crates/perry-runtime/src/gc crates/perry-runtime/src -g '*.rs' --max-count 80

printf '%s\n' '--- barrier_child_prologue and prologue implementation ---'
rg -n -C 10 'fn barrier_child_prologue|fn incremental_mark_barrier_value' crates/perry-runtime/src -g '*.rs'

printf '%s\n' '--- current test section ---'
sed -n '191,260p' crates/perry-runtime/src/gc/tests/barrier_decoded_parent.rs

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
# Use Python to extract only the targeted definitions and predicate behavior.
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path("crates/perry-runtime/src/value/addr_class.rs")
text = path.read_text()

for i, line in enumerate(text.splitlines(), 1):
    if line.startswith("pub(crate) fn is_plausible_heap_addr("):
        start = i
        break
else:
    raise SystemExit("is_plausible_heap_addr not found")

brace_count = 0
end = None
for j, line in enumerate(text.splitlines()[start-1:], start):
    brace_count += line.count("{") - line.count("}")
    if brace_count == 0:
        end = j
        break

print("--- is_plausible_heap_addr ---")
for j in range(max(1, start-5), end+5+1):
    print(f"{j}: {text.splitlines()[j-1]}")

body = "\n".join(text.splitlines()[start-1:end])
print("--- constants used in body ---")
for name in {"U64_HEAP_LIMIT", "U64_HEAP_MASK", "HEAP_UPPER", "HEAP_FLOOR", "is_bit_set", "is_unified_heap_addr", "gc_malloc_implausible"}:
    m = re.search(rf"\b{re.escape(name)}\b", body)
    print(f"{name}: {'FOUND' if m else 'NOT FOUND'}")

print("--- guard definition file references ---")
for ref in re.finditer(r"\b(GcTestIsolationGuard(?:::new|::\{)|struct GcTestIsolationGuard|impl Drop for GcTestIsolationGuard)\b", text):
    line = text[:ref.start()].count("\n") + 1
    print(f"{line}: {ref.group(0)}")

for file in Path("crates/perry-runtime/src/gc").rglob("*.rs"):
    s = file.read_text(errors="ignore")
    if "GcTestIsolationGuard" in s or "fn clear_marks" in s or "impl Drop for GcTestIsolationGuard" in s:
        print(f"\n--- {file} ---")
        for idx, line in enumerate(s.splitlines(), 1):
            if "GcTestIsolationGuard" in line or "impl Drop for GcTestIsolationGuard" in line or re.search(r"\bfn\s+clear_marks\s*\(", line):
                # show up to 12 lines before and 20 after
                lines = s.splitlines()
                lo = max(1, idx-12); hi = min(len(lines), idx+20)
                print(f"[{idx}-{lo}-{hi}]")
                for num in range(lo, hi+1):
                    print(f"{num}: {lines[num-1]}")

# Probe current function using read-only text extraction; do not run repo code.
def parse_function_body(text, func_name):
    lines = text.splitlines()
    for idx, line in enumerate(lines, 1):
        if f"fn {func_name}(" in line:
            start = idx
            brace = 0
            for j in range(start, len(lines)+1):
                brace += lines[j-1].count("{") - lines[j-1].count("}")
                if brace == 0:
                    return "\n".join(lines[start-1:j])
    raise SystemExit(f"function not found")

body = parse_function_body(text, "is_plausible_heap_addr")
print("\n--- extracted function body ---")
print(body)
print("--- address threshold references in whole file ---")
for pat in [r"\b[U+]?0x9000_0000_0000\b", r"\b9000_0000_0000\b", r"\btwo_tb\b", r"\b0x2_0000_0000_0000\b"]:
    for m in re.finditer(pat, text):
        print(f"0x9000_0000_0000/two_tb reference: line {text[:m.start()].count(chr(10))+1}")
PY

Repository: PerryTS/perry

Length of output: 50370


Add the closing mark-clear to this isolation guard.

barrier_child_prologue shades young on each rejected parent path, but GcTestIsolationGuard::Drop unsets marks per clear_marks()’ arena/malloc walk, not globally. Add a final clear_marks() after reset_remembered_set() or otherwise ensure no GC marks leak between tests.

🤖 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/gc/tests/barrier_decoded_parent.rs` around lines 211
- 253, Update the test cleanup at the end of the implausible-parent isolation
case, immediately after reset_remembered_set(), to invoke clear_marks() so marks
shaded by barrier_child_prologue are removed before the test exits. Preserve the
existing remembered-set reset and ensure cleanup covers marks outside the
arena/malloc walk.

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