Skip to content

perf(gc): make the "nothing due" GC check cheap on safepoint polls and trigger checks - #10253

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/gc-due-check-fast-gate
Closed

proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/gc-due-check-fast-gate

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Brief 1 of the #10166 per-call breakdown: the GC safepoint / trigger due-check cost.

What was slow

A runtime safepoint poll that finds nothing due cost about 950 instructions. That covers regex search quanta, the microtask pump and the event loop. re.exec makes 7 such polls per call, counted with a uprobe. gc_check_trigger pays a similar cost on every gc_malloc and JSON parse, and it evaluates the due trigger up to three times. The biggest pieces:

  • Every poll built a JsGcStepResult with a GcDebtSnapshot, which evaluates the nursery cap, the arena trigger and the old-reclaim band a second time. All three runtime callers discarded the result.
  • copying_from_space_in_use_bytes() summed block.offset over every Eden and active-survivor block on every read.
  • The budgeted step function carries a ~20 KB frame for the cycle machinery. Even the "nothing due" path paid its prologue.

Share of retired instructions in the due-check family before the change (release, perf record -e instructions:u):

workload share
1M hoisted re.exec ~21 %
400k small JSON.parse ~22 %
1M hoisted re.test ~11 %
2M await, 20M object literals, 3M string concat/join, 2M BigInt ops, 1M Map.set < 0.2 % each

What changed

No collection decision changes. Each piece is described in its doc comment.

  1. Debt-free step report. The budgeted step returns a GcStepReport. js_gc_step_*, js_gc_safepoint and the test entry point gc_runtime_safepoint() attach the debt after the step returns, which reads the same values. The runtime polls call the new gc_runtime_safepoint_poll(). Cycle start and stepping moved out of line into gc_budgeted_start_or_step.
  2. O(1) from-space occupancy (arena/from_space.rs). The bytes outside Eden's current block are cached, keyed on the heap generation. Every reset, detach, evacuation and survivor flip runs inside a HeapChange scope, which advances the generation. Every move of an arena's current now goes through Arena::set_current, which invalidates the cache, because the allocator can leave a block and later come back to it. Debug builds compare every cached answer with the block walk.
  3. One due evaluation per gc_check_trigger. DueTriggerMemo reuses the first answer unless it came from the JSON roundtrip at scale: every minor traces the whole live tree because the large stringify result is malloc-tracked (untraced promotion vetoed) #10169 one-shot leaf priority, which consumes its flag. The young cap reuses the old-gen pressure value the due trigger already read, cross-checked in debug builds. It also tests the census-seeded flag before the two thread-locals it used to read first.

Numbers

Shipping release profile (codegen-units=1, thin LTO) on perrymaster. perf stat -e instructions:u, minimum of 3 interleaved runs; the largest spread between runs was 0.044 %.

probe base this PR change
1M hoisted re.exec (2 captures) 29,015,250,523 25,376,993,583 −12.54 % (−3,638 per call)
400k small JSON.parse 3,892,905,172 3,414,573,557 −12.29 % (−1,196 per parse)
1M hoisted re.test 13,031,830,711 12,173,754,179 −6.58 % (−858 per call)
2M await 10,669,325,372 10,669,229,457 −0.00 %
20M object literals 12,299,784,036 12,298,778,827 −0.01 %
3M string concat + join 5,228,317,167 5,227,985,509 −0.01 %
2M BigInt ops 6,385,318,224 6,383,824,261 −0.02 %
1M Map.set 620,539,004 620,537,885 −0.00 %

Peak RSS (/usr/bin/time %M, 3 runs each) is unchanged within run-to-run noise on all eight probes.

A build with 16 codegen units showed ±0.5 % swings on unrelated probes, in both directions. Those were partitioning artifacts and disappear at codegen-units=1.

After this change, what remains of the due check in re.exec is about 6.6 % of instructions. The regex engine's own multiplier, seven polls per call, is a separate follow-up.

Equivalence

Probes: the 14 GC ratchet probes plus the 8 above. Env arms: each probe's declared env and a 1 MB nursery cap, each with the conservative stack scan on and off. Every run was done twice with PERRY_GC_TRACE=1 PERRY_GC_DIAG=1. The comparison takes every [gc-trigger] decision line verbatim, which includes the from-space, cap, arena, old-gen and malloc inputs of each decision. It also takes every trace record field except timing.

  • Conservative scan off: all 82 runs make the same decisions in the same order, with the same records. The only fields that differ are native_stack_maps.frames_visited and counters that also differ between two base runs: old-page object counts, write-barrier cache hits and layout-scan counters.
  • Conservative scan on: micro, r14_grow_then_churn and strs diverge starting at a collection whose conservative native-stack scan found exactly one root fewer (5 vs 4, 8 vs 7). That collection retained 80 B and 110 KB less, and later decisions follow from the smaller heap. A part-1-only build matched base on these probes. The cache removes a stale stack word the block walk used to leave behind, and the conservative scan sees stack garbage.

Fault injections

Each was applied on its own, run, and reverted.

injection result
Remove the invalidation in Arena::set_current cached_occupancy_matches_the_walk_after_returning_to_the_primed_block fails
Pass 0 as the reused old-gen pressure the debug cross-check aborts the suite in large_presized_array_grows_its_dense_frontier (left 0, right 917552)
Mark the leaf-priority answer repeatable only_the_leaf_priority_answer_is_unrepeatable fails
Make DueTriggerMemo reuse every answer both memo tests fail
Make gc_runtime_safepoint_poll a no-op microtask_runner_tail_pays_bounded_safepoint_under_pressure and stdlib_pump_and_perry_poll_pay_debt_through_shared_scheduler_surfaces fail
Skip the census seed allocation_census_seeds_the_first_cap_before_any_minor fails
Drop the due trigger passed to the out-of-line start 8 budgeted-step and host-safepoint tests fail

Before this PR, nothing tested that the due trigger feeds the tenured-proportional term of the cap: the second injection passed the whole suite. The debug cross-check is what makes it fail now.

Validation

  • cargo fmt --all -- --check
  • cargo check -p perry-runtime --no-default-features --features full (regex off)
  • RUST_TEST_THREADS=1 cargo test -p perry-runtime --lib: 3800 passed, 0 failed (macOS, debug, so every cached read is cross-checked)
  • clippy on perry-runtime: 0 diagnostics on added lines
  • scripts/gc_runtime_root_holders.py (+ --self-test): new not_a_gc_pointer verdict for SEALED_YOUNG_BYTES; PASS1_MARKED window re-audited and re-pinned for gc/mod.rs and gc/policy.rs
  • scripts/run_lint_gates.sh (compile tier included, macOS): 3 of 83 fail, none from this change.
    • "Public benchmark evidence freshness" is the known red on main.
    • The -D warnings all-targets check stops on four pre-existing test-build warnings in untouched files (object/global_this_webassembly.rs, ic_miss/ic_slow.rs).
    • "API docs drift" regenerates a bun-pty manifest entry that main's docs do not have yet.
  • gc-stress local replay on perrymaster (gc_repsel_matrix.sh --arms pr + fan-in + instrument smoke), fix and base builds side by side: both 428 pass / 167 unverified / 7 fail, and all 581 distinct cells have the same status. The 7 failures are all test_gap_gc_http2_pending_event_callback_rooting, an output mismatch even in cells with zero collections, on base too.
  • gc-ratchet local replay (gc_ratchet.py measure, 14 probes, 3 repeats): all probes pass the Node oracle, and the medians of copied/promoted/freed bytes and objects, heap used/total, minor cycles and step cycles are identical to base on every probe. RSS and wall time in that run are not comparable: base was a codegen-units=16 build and the host load was above 20. The like-for-like RSS check is the table above.

GitHub runners are down, so the local replays above are the gate.

Coordination notes:

…d trigger checks

Runtime safepoint polls (regex quanta, the microtask pump, the event loop),
gc_malloc and the other gc_check_trigger callers spent hundreds to thousands
of instructions answering "is a collection due?" when nothing was. Three
changes, each leaving every collection decision unchanged:

- Runtime polls no longer build a JsGcStepResult. The budgeted step returns a
  debt-free GcStepReport; the FFI and test entry points attach the
  GcDebtSnapshot after the step returns, which reads the same values.
  The cycle start/step machinery moved out of line so a no-trigger poll does
  not pay its multi-kilobyte frame.
- copying_from_space_in_use_bytes() is O(1) between layout changes: the
  bytes outside Eden's current block are cached, keyed on the heap
  generation (every reset, detach, evacuation and survivor flip runs inside a
  HeapChange scope), and every move of an arena's current block goes through
  Arena::set_current, which invalidates the cache. Debug builds compare every
  cached answer with the block walk.
- gc_check_trigger evaluates the due trigger once instead of up to three
  times, reusing the answer unless it came from the PerryTS#10169 one-shot leaf
  priority; the young cap reuses the old-gen pressure the due trigger already
  read (debug cross-checked) and tests the census-seeded flag first.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 10 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 8 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6e370eca-5d27-445d-88ec-6d629b53412a

📥 Commits

Reviewing files that changed from the base of the PR and between eb13fa1 and 4ba102d.

📒 Files selected for processing (16)
  • changelog.d/10253-gc-due-check-fast-path.md
  • crates/perry-runtime/src/arena/block.rs
  • crates/perry-runtime/src/arena/from_space.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/promote.rs
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/arena/reset.rs
  • crates/perry-runtime/src/gc/heap_generation.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tenuring.rs
  • crates/perry-runtime/src/gc/tests/young_leaf_route.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • crates/perry-runtime/src/regex/perex_runtime.rs
  • scripts/gc_runtime_root_holders.json

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

Landed in merge train #10261: #10261. The merged main tree matches the validated train, and the fresh-head patch audit confirms the changes arrived.

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