perf(gc): answer arena valid-pointer membership from the census runs (#7592) - #7646
Conversation
|
Warning Review limit reached
Next review available in: 1 minute You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
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 |
…7592) The full collection's valid-pointer census maintained a BTreeSet over the same addresses the census walk was already recording, in the same order, in arena_runs. ArenaObjectCursorBuilder hands the headers over in ascending address order, so a floor lookup that lands on the query IS the membership answer -- the B-tree was a shadow costing one insert per live arena object. On json_pipeline 500k that shadow is 245.5 ms of a 748.3 ms full collection (phase_us.build_valid_pointer_set). Membership for arena starts now comes from the runs; the B-tree keeps only malloc-tracked starts, which have no address order to exploit. arena_run_firsts mirrors each run's first key into one contiguous vector so the run-level binary search reads 8-byte fences instead of chasing a Vec header per probe. That part is load-bearing: without it the lookup path is worse than the B-tree (+99 ms on trace_worklist at 500k); with it, trace_worklist improves too.
… need Answering arena membership from the address-ordered census runs makes run sealing load-bearing for memory safety: the BTreeSet was complete after every push_arena, the runs are only complete after finalize() seals the open one, and a query before that is a false negative — a dropped root, not a missed optimisation. Adds a debug_assert on the query path and a test covering every censused start (the existing sliced-build test checks only the first 16, all in the first sealed run, so it passes unchanged if every later run is lost) plus the negative direction: an interior pointer floors to its object and must still be rejected as a start. Sabotage-verified: dropping the seal in finalize() and misaligning the fence mirror each redden the new test. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
Audit — merging as v0.5.1367, with one additionThe change is right and the argument for it is exact: The What I added: the ordering requirement this createsMembership from sorted runs has a precondition the A query on an unsealed set is a false negative, and a false negative here is not a missed optimisation — the conservative scan drops the root, the object is swept live, and it surfaces cycles later as Added a And a gap in the existing coverage
Added Verification
On the one ratchet cell that moved ( Gates: 20/20 from the #7645 was the right call to file rather than take — it is a guard on the moving collector, and a 21.8% prize is exactly the kind that gets taken without the pin-site completeness gate it needs. |
9444b98 to
42d66e3
Compare
The version bumps in #7646/#7648 edited Cargo.toml and staged Cargo.lock without a cargo invocation in between, so the lock kept 0.5.1367 and every build dirtied the tree. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
Re-deriving the next
json_pipelinelever after #7624 and #7633 (#7592) turned up a whole-heap data structure that shadows one the collector was already building.The bug-shaped part
ValidPointerSetkept two structures over the same addresses:arena_runs: Vec<Vec<usize>>— the census walk's arena object starts, handed over byArenaObjectCursorBuilder::new(ArenaWalkOrder::Address)in ascending address order, documented as existing forenclosing_object's floor lookups.lookup_set: BTreeSet<usize>— exact membership, fed by the samepush_arenacall, plus malloc starts.The runs are sorted by construction, so a floor lookup that lands on the query already is the membership answer. The B-tree was a shadow: one insert per live arena object, for a question the runs could answer.
On
json_pipeline500k that shadow is 245.5 ms of a 748.3 ms full collection — the GC's ownphase_us.build_valid_pointer_set— i.e. 12.6% of thebuild_outphase, and it was the second-largest single leaf in the symbolicated profile (BTreeSet::insert, 139 of 1504 build_out samples).The fix
push_arenastops inserting; membership for arena starts isfind_arena_floor(ptr) == Some(ptr).lookup_set→malloc_lookup, holding only malloc-tracked starts, which have no address order to exploit. Skipped entirely when empty.arena_run_firsts: Vec<usize>mirrorsarena_runs[i][0]into one contiguous vector, so the run-level binary search reads 8-byte fences instead of chasing aVecheader per probe. This is load-bearing, not tidiness — without it the lookup path is measurably worse than the B-tree (see below).lookup_count()keepssnapshot_for_testsmeaningful (arena starts + malloc starts), socycle_state.rs's "grows by exactly one per stepped object" assertions still hold.No policy, pacing, trigger, promotion or root-set behaviour is touched. The membership set is identical; only how it is stored and probed changes.
Measured — pinned quiet mini, interleaved A/B, 7 rounds,
PERRY_NO_AUTO_OPTIMIZE=1, pinnedPERRY_RUNTIME_DIRbuild_outphasephase_us.build_valid_pointer_setphase_us.trace_worklistThe lookup path got faster, not slower — but only because of the fence array. An earlier probe kept
arena_runsasVec<Vec<usize>>and searched it withrun.first()per probe: that arm movedtrace_worklistthe wrong way, 388.8 → 493.1 ms (+99 ms over ~9.6M lookups), and netted only −6.3% onbuild_out. Replacing the per-probe pointer chase with the contiguous fence array turned that +99 ms into −62 ms and took the whole change from −6.3% to −15.4%. Recorded because the intermediate result reads like "the B-tree was buying something" and it was not — it was buying an indirection.Semantics — the collector's own counters
PERRY_GC_TRACE=1, both arms, both sizes, every non-timing key of everygc_cycleevent compared field by field: 0 differences. Same cycle count, same kinds, same triggers, samepromoted_bytes/promoted_objects/freed_bytes/copied_*, sameremembered_set,old_pages,layout_scans,root_sources,sweep.gc-ratchet — both arms, back to back, two independent sessions
Run on the dev Mac rather than the pinned mini: the shipped
perrydyn-links homebrewlibz3.4.15and the mini has 4.16, so the compiler will not launch there. That is the right host for this comparison anyway — the gating retention and GC-accounting families are load-independent by the artifact's own cross-host evidence, and the wall/RSS story for this change is thejson_pipelineA/B above, which was done on the pinned mini.12 probes × 9 gated semantic metrics,
--repeats 5:minor_cycles,step_cycles,copied_objects,copied_bytes,promoted_objects,promoted_bytes,freed_bytesandheap_total_bytescell matches exactly, on all 12 probes.correctness=passon all 12 in both arms.heap_used_bytescells move, both downward, and both reproduce exactly across two independent sessions (0% within-session spread over 5 repeats in every arm):08_map_set_sidetables12_large_live_set12_large_live_set.heap_used_bytesis the celltolerances.jsonalready de-gates by probe override, with a documented spread of 9,072 B over 36 runs; −360 B is a twenty-fifth of that.08_map_set_sidetablesis gated, so it needs an explanation rather than a shrug.heap_used_bytesis read after the probe's own explicitgc()— the one site that forces the conservative native-stack scan ([gc-scan-fallback] site=manual_collect) — andjs_arena_statsreportsΣ block.offset − old_free_bytes(). What a conservative scan retains depends on stack and callee-saved-register residue at the moment of the scan, and this change alters the runtime's own code path immediately before it (aVecpush loop instead of a B-tree insert loop leaves different values in the callee-saved registerssetjmphands the scan). Fewer accidental false roots ⇒ more swept ⇒ largerold_free_bytes⇒ lowerheap_used. Both moved cells are in that direction; the band on this family is one-sided (growth is the regression), so neither is acheckfailure.gc_ratchet.py classify— the tooltolerances.jsonprescribes for exactly this — settles it quantitatively. It re-reads each probe withPERRY_CONSERVATIVE_STACK_SCAN=offand splits retention into precise (what the collector's own roots account for) and excess (false-root residue):08_map_set_sidetables12_large_live_setPrecise retention is byte-identical on all twelve probes. The
08_map_set_sidetablesmovement is exactly its false-root residue — 36,504 B in the base arm, 0 B here — i.e. the fix's conservative reading equals the base's precise reading to the byte. Nothing about real retention changed; a different code path immediately before the scan left different register/stack residue for it to trip over, and this arm trips over none.Gates run
cargo fmt --all -- --check;scripts/check_file_size.shgc_store_site_inventory.py(+--self-test),addr_class_inventory.py(+--self-test),class_id_collisions.py,raw_handle_debt.py(+--self-test),gc_gate_wiring_check.py(+--self-test),gc_matrix_liveness_check.py(--self-test,--check-registry),check_test_registration.py(+--self-test) — all passRUST_TEST_THREADS=1 cargo test --profile perry-dev --lib -p perry-runtime --no-fail-fast— 1902 passed, 0 failed, 3 ignoredNot in this PR
The larger item on the same profile is the copying minor's eligibility preflight — a second full traversal of the young graph, 21.8% of
build_outand 14.5% of total wall — filed separately. It is a guard on the moving collector and needs a pin-site completeness gate, a sabotage test and a deliberate ratchet counter shift before it can move.