Skip to content

gc: RewriteStatepointsForGC over managed-pointer SSA — the measured-only road to native-root file-size parity #7174

Closed

Description

@proggeramlug

The 2026-07-31/08-01 stackmap campaign (#7108, branch exp/stackmap-viability, full write-up in docs/statepoint-gc-experiment.md) ends with every road to native-root file-size parity converging on one experiment that has not been run: RewriteStatepointsForGC over managed-pointer SSA.

Why this is the only remaining road, now demonstrated rather than argued:

  • The explicit statepoint bridge is correct and speed-competitive (quiet-host geo −1.1% vs shadow, RSS flat) but its metadata measures 3.83 MB against 150 KB of text recovered on the real test-drizzle-pg app (~25×) after a −59% reduction campaign (audited call effects, noreturn elision, safepoint contract). The remaining records are dominated by base/derived pairs and per-safepoint spill slots that explicit emission cannot avoid.
  • The cheap alternative was built and killed: a compact per-function metadata mode measured 10–13× smaller and was deleted as a proven negative result (bd066d62b, deleted at a95f59e96). A ten-line churn loop corrupts deterministically under moving minors, bit-identically even with the walker's visits disabled — the mutator reads from-space through stale heap-derived values in optimized SSA. The same module carries 79 gc.relocates under the statepoint backend. Design law: root metadata without relocation semantics is unsound under an optimizing compiler. Per-call plain maps only kept the window small enough for probes to pass (that mode is also deleted — its Register R#N locations are unrecoverable by construction).

What the experiment is (#7108 costed it as option 1, "weeks"):

  1. Represent GC-capable values as ptr addrspace(1) in SSA — the whole-NaN-box carrier is already proven (Stack maps vs the codegen shadow stack: viability experiment (Q1 passes; 0.44 MB text saved for 4.5-16.6 MB metadata) #7108 Q1: bitcast double → i64 → inttoptr, folds to zero instructions, relocated end-to-end at runtime).
  2. Tag functions gc "statepoint-example", let opt -passes=rewrite-statepoints-for-gc insert statepoints, results, relocates, and downstream-use rewrites — deleting the hand-emission and its conservative CFG-union liveness.
  3. Toolchain: requires opt (Homebrew LLVM; Xcode ships none). Verified working end-to-end in Stack maps vs the codegen shadow stack: viability experiment (Q1 passes; 0.44 MB text saved for 4.5-16.6 MB metadata) #7108. Gate the mode on opt presence; this is the distribution decision that issue named, scoped here to research.

Acceptance: the existing gate stack (8 probes × forced evacuation × verify-walker vs pinned Node, statepoint-report deltas, quiet-host matrix) plus the real-app criterion that defines success: __llvm_stackmaps + __text delta vs shadow ≤ 0 on test-drizzle-pg — i.e., LLVM-computed liveness (dead-root pruning is free in RS4GC) must close what audited elision provably cannot.

Interaction: repsel promotion (#7034 line) independently shrinks the recorded set — every value proven non-pointer deletes its records in either backend. The two compose; neither replaces the other.

Activity

  1. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Status update: the first pipeline slice exists on exp/stackmap-viability at 503ecb7e9 — this issue now has running code and failing tests instead of a costed design.

    Working: root-alloca surgery to ptr addrspace(1) (fail-closed per function, bail to the explicit statepoint backend on any unrecognized use shape), gc "statepoint-example" tagging, audited non-collecting callees as "gc-leaf-function" call-site attributes, and a compile_ll_to_object pipe through opt -passes='default<O2>,rewrite-statepoints-for-gc' gated on PERRY_RS4GC=1 (loud failure without opt). Toolchain must be version-matched: LLVM 22 opt output is unparseable by Apple clang 21 (nocreateundeforpoison), so PERRY_LLVM_CLANG must point at the same LLVM's clang — confirming this issue's "treat upgrades as an ABI migration" requirement concretely.

    Measured status with the surgery genuinely engaged: 5/8 gc-ratchet probes pass under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1, byte-matched to the pinned Node oracle. Probes 01/06/08 fail — the first concrete reproducers of the double-typed dataflow frontier this issue predicted ("expose live heap references as managed ptr SSA … separate pointer payload from scalar/tag"): NaN-box values crossing statepoints as double/i64 derivatives that RS4GC does not track. Metadata is not yet competitive either (probe 01: 6,992 B vs the explicit bridge's 5,320 B post-audit) — liveness pruning does not yet beat the audited elisions.

    Traps recorded for whoever continues: the first "all green" run was the fail-closed fallback silently carrying correctness while the surgery bailed on every function (the recognizer only knew the unit-test alloca i64 idiom; real roots are alloca double) — caught only by comparing record counts (200 vs the bridge's 55), not by any probe. Assert surgery liveness (grep for the cast signatures or compare record counts) before believing any RS4GC A/B.

  2. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Second increment, same session (16986ef85): the slice is now fully gated — 16/16 (8 probes × forced evacuation + verification, 8 × walker-verify), after two findings that are now design rules in the doc:

    1. Statepoint placement must precede cast-merging optimization. default<O2> before RS4GC fails 3/8 probes: GVN/CSE merges the per-site ptrtoint/bitcast chains across future statepoint sites, recreating the stale-double hazard inside opt. function(mem2reg) alone is the sound pre-pass; clang's full optimization after statepoint insertion is safe by construction. (The branch's design law stated positively: relocation semantics must exist before the optimizer may move heap-derived values.)
    2. Leaf-attribute transfer verified effective by record count: 200 records (no attrs applied) → 103 (call-site "gc-leaf-function" live) vs the audited explicit bridge's 55 on probe 01.

    The remaining ~48-record delta is RS4GC's zero-live statepoints — calls with no live gc values still get a 52 B record (header + 3 constant locations) that the bridge's per-call liveness skips outright. Post-compile record-stripping was analyzed and is a dead end for file size: dropped records can preserve function-table relocations (record arrays sit after the reloc'd table, and instruction offsets are function-relative), but shrinking a Mach-O section means a full object rewrite, and zero-padding keeps the disk bytes — parse-time win only. The honest remaining options are upstream (teach the pass to omit empty-live records — an LLVM patch), or accept the constant and win the axis via liveness quality on real code, where the bridge's conservative CFG-union retains roots RS4GC's SSA liveness drops.

    Current metadata standing on probe 01: bridge 5,320 B / RS4GC 8,072 B. Next measurement that decides direction: the real-app (test-drizzle-pg) comparison, where SSA-liveness pruning has far more dead-root mass to remove than the probes offer.

  3. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Real-app measurement (abaf2aa6a), and it flips the challenger hierarchy: on test-drizzle-pg (133 modules), the RS4GC arm measures

    __text __llvm_stackmaps file
    shadow 20,376,748 0 28,474,576
    explicit bridge + contract 20,226,728 3,757,520 32,008,560
    RS4GC 20,128,708 3,875,416 31,957,792

    RS4GC generates the leanest text of any arm — 248 KB below shadow, 98 KB below the hand emission — and its metadata lands within 3.1% of the fully-audited bridge despite the zero-live-record constant and mem2reg-only liveness: SSA pruning compensates at scale exactly as this issue predicted. Smallest total native binary, 16/16 gate matrix, structural relocation model.

    Direction for this issue is therefore settled: RS4GC is the preferred native backend, and the explicit bridge becomes a deletion candidate (a large one — its whole per-call rewriter and conservative CFG-union liveness) once RS4GC grows has_try coverage and either an upstream fix for empty-live records or acceptance of their constant. Remaining before any default-flip conversation is unchanged from the top of this issue: the ~25× gap versus shadow's zero metadata still routes through repsel promotion — but the native side of the ledger now has its winner.

  4. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Runtime datum completing the slice's characterization: 8 probes × 9 interleaved reps (loaded host, arms share load — directional):

    • shadow 218.1 ms geo-mean
    • explicit bridge + contract 216.3 ms (−0.83%)
    • RS4GC 216.1 ms (−0.93% vs shadow) — fastest arm measured

    Consistent with the quiet-host mini matrix's finding that the statepoint family runs ~1% ahead of shadow, now with RS4GC's leaner codegen on top. Slice standing at session end: 16/16 gate matrix, leanest __text of any arm (−248 KB vs shadow on drizzle), smallest total native binary, fastest directional runtime. Open: RS4GC-arm RSS (expected flat like the family, unmeasured), has_try coverage, empty-live-record cost (upstream), and the quiet-host re-run of these runtime numbers.

  5. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    RSS cells completing the slice's table: RS4GC max-RSS is flat with shadow on the four churn-heaviest probes (27/27, 36/36, 37/38, 25/25 MB) — the family's RSS-neutrality holds for the RS4GC arm. Every axis of every configuration on the branch is now measured; the characterization table in docs/statepoint-gc-experiment.md has no empty cells.

  6. proggeramlug commented on Aug 1, 2026

    @proggeramlug
    ContributorAuthor

    Projection correction, measured: the shared assumption that landed repsel promotions erase native-root metadata has a measured slope of zero — batch.ts under statepoints is byte-identical with all promotion knobs off (24,752 B / 198 records / 33 slots, #7133-scoped knobs). Landed classes remove calls, not roots: they promote values the rooter's type analysis already classified non-pointer. Metadata erasure comes only from maybe-pointer-population promotions (untyped locals, temporaries, dependency JS — repsel Tracks E/F territory), which materially re-costs the '25× closes via repsel' path and strengthens the case that this issue's RS4GC liveness work and the repsel typed-slot story must land together. Details in docs/statepoint-gc-experiment.md on the branch.

  7. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Now part of one consolidated GC plan — see docs/src/internals/rfc-rooting-by-construction.md ("Where this sits in the one GC correctness plan", landed in #7292). Read that before starting work here, so five documents stop describing parts of the same shape.

    What changed for this issue: it is layer 2 of three, and it has a dependency. #7108 concluded that "the text-IR-plus-stock-clang architecture is what rules the cheapest design out" — Perry emits textual .ll to a user-supplied clang and controls neither the pass pipeline nor stackmap emission. #7241 (in-process LLVM) removes exactly that constraint, and independently verified that gc "statepoint-example" constructs, verifies and emits.

    ⇒ Sequencing is 0 (#7241) → 2 (this). Starting statepoint migration before the in-process backend means fighting the architecture #7108 already measured as the blocker.

    The first thing this layer must prove, before any migration commitment: that safepoint density is a usable lever on metadata size. The measured trade is 438,848 B of hot text saved against 4.5–16.6 MB of cold .llvm_stackmaps. The model is 24 B × (safepoint, root) pairs over 62,731 candidate safepoints, so filtering to genuinely-allocating calls should shrink it proportionally — but that is expected, not measured, and minimal binary size is a stated project goal.

  8. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Cross-link: #7302 (try/catch → invoke/landingpad, deleting the setjmp path) is the prerequisite for removing this experiment's unsound has_try plain-stack-map fallback — with explicit unwind edges, statepoints can cover every function and relocations exist on both the normal and unwind paths. Phase 0 spike results and the cross-Rust-frame unwinding matrix are in docs/invoke-eh-experiment.md on exp/invoke-eh.

  9. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    #7305 landed — the unsound has_try fallback can now be deleted.

    exp/stackmap-viability currently excludes has_try functions and routes them to a plain-stack-map fallback that is known-unsound, because a longjmp can jump past a gc.statepoint's gc.relocate write-back.

    That is fixed at the source. #7305 replaced setjmp/longjmp with LLVM invoke/landingpad (SEH funclets on windows-msvc) and deleted the setjmp path wholesale — volatile_setjmp.rs (376 lines), setjmp_abi.rs (193), the returns_twice/noinline attribute groups, the try-region volatile store tracking, and the PERRY_EH development flag. There is one exception lowering now, not two.

    With explicit unwind edges, relocations exist on both paths and no jump can skip one. So the exclusion and the fallback are both obsolete — and removing them closes the last soundness hole in the statepoint design rather than merely narrowing it.

    Two other things that changed underneath this work:

    Sequencing and the layer model are in docs/engine-plan.md; tracker is #7294.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions