Skip to content

gc: reduce safepoint density (safepoint polling instead of every-call statepoints) #8596

Description

@proggeramlug

Summary

Perry treats every call as a GC safepoint: rewrite-statepoints-for-gc wraps each non-leaf call in a gc.statepoint and relocates every live root across it. That is correct (any callee might collect) but it makes the safepoint count of a function equal to its call count, which is the × safepoints term in the RS4GC relocation fan-out live_roots × safepoints behind #8583 (the Claude Code @main has ~106k call sites).

Many production GC'd VMs instead use safepoint polling: collection can only begin at a bounded set of poll points — loop back-edges, function returns/entries, and allocation sites — and ordinary calls are not safepoints unless the callee itself polls. This shrinks the safepoint set dramatically without losing the guarantee that a collection can always make progress, because a thread reaches a poll in bounded time.

Proposal (exploratory — needs design)

Move from "every call is a safepoint" toward a polling model:

  • take safepoints at loop back-edges, allocation sites, and returns, rather than at every call;
  • a non-allocating / non-polling call is a gc-leaf-style edge that does not relocate roots;
  • keep a mechanism to bring a thread to a safepoint on demand (poll flag the callee checks), which perry already has pieces of (GC_SAFEPOINT_PENDING, js_gc_loop_safepoint, the loop-poll path).

Why it's worth it

Unlike #8595 (outlining), which fixes the giant-function symptom, reducing safepoint density shrinks the × safepoints factor for every function in the codebase, not just the outlier — lowering RS4GC cost and stack-map size across the board, and reducing the number of points at which roots must be kept live (better register allocation).

Scope / risk

This is the largest of the three and a genuine GC-architecture change: it affects collection latency/progress guarantees, the interaction with the moving scavenge (#7019), write barriers, and the conservative-scan fallback. It should be scoped and prototyped carefully — filed here so the option is tracked rather than lost, not as ready work.

Context: surfaced while fixing #8583 (RS4GC relocation fan-out). Complements #8595 (outlining) and the root-spilling stopgap in #8583. Root-lowering background: #7370 (native roots default), #7280/#7173/#7174.

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 22, 2026
  2. proggeramlug commented on Aug 22, 2026

    @proggeramlug
    ContributorAuthor

    Safe increment filed: #8598

    Four provably-non-collecting helpers reclassified as gc-leaf-function (verified against their runtime bodies + already in the NONCOLLECTING authority): js_typed_feedback_closure_direct_call_guard, js_implicit_this_get, js_tdz_suppress_begin/_end. Zero semantic risk, trims safepoints across all functions. Not the fan-out fix — that's the polling model below.

    Design notes — the real lever (safepoint polling)

    The × safepoints term shrinks materially only by moving from "every non-leaf call is a safepoint" to safepoint polling: collection may begin only at a bounded set of poll points; ordinary calls stop being safepoints, so RS4GC statepoints (and relocates) collapse to the poll/alloc sites.

    • Pieces perry already has: GC_SAFEPOINT_PENDING + js_gc_loop_safepoint (the loop back-edge poll), and gc/pressure.rs already defers a pressure-triggered collection to the next poll when a generated frame is live. Today that coexists with statepoints-at-every-call; polling would make the poll points the only safepoints.
    • What it touches: (1) codegen — emit poll checks at loop back-edges, allocation sites, and returns, and stop treating calls as safepoints in function/precise_roots.rs; (2) the moving scavenge (gc: default-on evacuating young-gen scavenge (moving minor at precise safepoints) #7019) — a moving collection may run only where all live frames' roots are precisely known, i.e. a poll (a non-moving one can be conservative anywhere); (3) the conservative-scan fallback for a thread caught between polls; (4) the write-barrier / AllocNoReentry "alloc-point valve" contract, which already assumes deferral-to-safepoint semantics; (5) invoke/unwind rooting (codegen: lower try/catch to LLVM invoke/landingpad, delete the setjmp/longjmp path #7302).
    • Why it's separate + large: it changes collection-latency and progress guarantees (every loop back-edge must poll; no unbounded poll-free call chain), so it needs a flagged prototype validated against the gc-stress suite before it can be a default. This is where most of gc: reduce safepoint density (safepoint polling instead of every-call statepoints) #8596's value — and its risk — lives.

    Bonus finding (checker over-inclusion — not a live bug)

    While auditing, two helpers listed in scripts/gc_root_dominance_check.py's NONCOLLECTING set actually allocate; the Rust classify_direct_callee correctly leaves both Unknown, so there is no miscompile, but the checker could pass a build with stale roots across such a call:

    • js_closure_unbox_callee_checked (closure/unbox.rs:25) — non-callable path is throw_not_callable() → js_throw_type_error_not_a_function, which allocates the TypeError and unwinds.
    • js_class_object_pin_parent (object/class_registry/parent_static.rs:289) — calls js_string_from_bytes and js_object_set_field_by_name; both allocate.

    Worth tightening the checker's set (or documenting why they're admitted there). js_object_mark_class is undetermined — it calls transition_object_shape_to_class, which needs a shape-descriptor-allocation audit.

  3. proggeramlug commented on Aug 24, 2026

    @proggeramlug
    ContributorAuthor

    Closed by #8689, landed via #8700 (squash 8224d879a).

    The implementing PR was landed through a batch wrapper PR rather than merged directly, so GitHub's Closes #8596 keyword never fired at merge time. Closing manually.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions