Skip to content

gc: a pacing full at about the cost of its live mark; promoted-cohort fulls (#10182) [land after #10249] - #10241

Closed
proggeramlug wants to merge 27 commits into
gc/full-throughputfrom
gc/pacing-full-cost
Closed

proggeramlug wants to merge 27 commits into
gc/full-throughputfrom
gc/pacing-full-cost

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Draft for #10182, stacked on #10220 (base gc/full-throughput, 9a05821b9e).

Status: the two regressions this description opened with are fixed, and the acceptance numbers below are met.

  • Full cost goal met. A pacing full on the 20 MB tree is 30-32 ms, against 62-68 ms in gc: make a synchronous full cheaper per live object (#10182) [pacing retry does not meet acceptance] #10220.
  • Target rows pass on the authoritative host. Re-measured on the quiet bench mini with Node 26.5.1 and Bun 1.3.14 in the same run, the four 20 MB rows are 0.94-0.97x the best CPU and 0.75-0.77x the best peak RSS. The CPU miss this description reported came from comparing against stale stored numbers taken under different load.
  • records_array_8m:scan keeps its lead. 0.96x CPU and 0.83x peak RSS once perf(json): count a compiled loop's whole-array materialization as traversal evidence #10249 lands, which stops that row paying for the lazy tape and an eager materialization at once.
  • Both ratchet regressions fixed, in commits on this branch. 14_grow_then_churn went 0.63 s to 0.29 s, better than its 0.44 s base, with peak RSS 284.6 to 65.1 MiB. 12_large_live_set is back to base at 0.24 s and 109.0 MiB. Every JSON row's collection schedule is byte-identical to the state before those commits.

Two fixes added after the first review

A cohort full feeds the survival of the blocks its own minor promoted. Every full resets the untraced-promotion budget, so with cohort fulls running every ~26 MB the budget never reached the size that forces a measuring minor. A stale 1000-permille reading from a program's growth phase then kept every later minor promoting untraced, and the fulls did all the reclaiming: on 14_grow_then_churn, 26 of 28 minors promoted untraced and 13 cohort fulls each re-marked the whole live cache.

The fix measures, with the full's own mark, how much of what the minor at the same safepoint promoted is still reachable, and feeds that to the promotion predictor. It is fed only when it provably equals what a minor would have measured: no parent left unmarked by the full, on the minor's own dirty old pages, refers into the promoted blocks. On the JSON rows that test fails (the dead predecessor tree's born-old array still refers into them), so nothing is fed and their schedules do not change. On the probe it holds, the predictor learns 4 permille, and the minors go back to copying.

Feeding the figure unconditionally below the promote threshold was measured and rejected: 20 MB parse +20.0 % CPU and +54.9 MiB, sparse +19.6 %, scan +18.4 %, records_object_20m:parse +18.8 %, 8 MB scan +14.1 %.

Only in-place promotions fill the cohort. A copying minor tenures an object only after it has already survived a minor, so a full scheduled for tenured bytes is futile by the one measurement a minor has. 12_large_live_set's only cohort full was reached by 21.2 MB of tenured bytes and reclaimed 8.3 MB; every cohort full on the JSON rows was reached by in-place promotions alone. The old-reclaim baseline credit still takes every promoted byte.

What changed

The full gets cheaper (every soundness claim has a planted test and a sabotaged twin that shows the harm):

commit change planted test / sabotaged twin
ea8a8ff59c GcCycleState::new_full no longer expands every described promoted page run. PendingOldUnregister::defer expands the runs on a dead object's pages before it invalidates the header. sweep_described_runs / sabotaged_expansion_order_keeps_counting_a_freed_object
31b07d915f An unbounded census hands whole blocks to census_whole_block: one loop that parses headers, sets start bits and records block facts. census_whole_block (compares against the stepped census) / sabotaged_whole_block_census_is_caught_by_the_comparison
ef57bdb203, 5c6d287e57 The hole-list rebuild skips live blocks the census proved hole-free, filtering blocks in place. sweep_hole_rebuild / sabotaged_hole_census_loses_the_hole
90dd47f908 An unbudgeted sweep walks each block in one pass (fast path for marked, unpinned, unforwarded objects). sweep_whole_block (compares against the stepped sweep) / sabotaged_whole_block_liveness_is_caught_by_the_comparison
f6fd156527 The mark reads proxy_trace_active and the weak-holder fact once per object and walks Range descriptors inline. GcMutableSlot::external is classified lazily. The layout-slot counter checks its armed flag inline. mark_slot_hoists / sabotaged_weak_holder_fact_keeps_the_weak_target_alive
d06177d792 A census block is found through a direct-mapped 1 MiB window index. census_block_windows (compares against the binary search) / sabotaged_block_window_lookup_is_caught_by_the_comparison
8b8119716a An unforwarded Leaf-descriptor object is marked without being queued. leaf_marks / sabotaged_forwarded_leaf_loses_its_target
9106c65ccb, 0b0fec7128 Worklist prefetch; prefetch a range's children before marking them. (no soundness claim)
90f99d2ce5 Between the census and the root scan, a full zeroes 16 KiB of dead stack below its own frame (see RSS below). scrub_dead_stack (planted sentinel below SP)

Promoted-cohort fulls (58159c264d, 392fce3104; gc/promoted_cohort.rs, gc/trace/adopt_census.rs):

  • Trigger. In gc_safepoint_moving_minor, after a copying minor at a precise safepoint, a full runs when the bytes promoted since the last full reach max(nursery cap, old live at last full << shift).
    • Its roots are the safepoint's precise roots, with no forced conservative scan.
    • Its site is safepoint_promoted_cohort; its trigger kind is OldGenBytes.
  • Backoff. A full that reclaims less than 50 % of its cohort raises shift, capped at 3. A productive full resets it.
  • Old-reclaim baseline untouched. Promotions are still credited to it, so the two tests gc: a full mark-sweep promotes its Eden survivors in place; bounded promoted cohort (#10182) [does not meet acceptance] #10204 broke still pass: an_untraced_promotion_credits_the_old_reclaim_baseline and test_old_reclaim_band_is_proportional_and_promotion_credits_baseline.
  • Census adoption. When the minor may reach the bound, stamp_and_index_block records each promoted block's census (start bits, counts, type and obligation facts) while it stamps. The cohort full adopts those blocks instead of reading them again.
    • In cfg(test), every adopted block is re-walked and compared.
    • The record is discarded at the end of the safepoint.
  • Diagnostics. PERRY_GC_DIAG=1: [gc-promoted-cohort] full cohort= bound= reclaimed= productive= adopted_census_blocks= backoff_shift=; [gc-trigger] gains promoted_since_full= / cohort_bound=.
  • Tests. promoted_cohort (7 tests, including sabotaged_backoff_charges_a_retaining_schedule_a_full_per_live_set) and adopt_census (sabotaged_promotion_census_loses_rooted_objects).
  • Holder inventory. Six new thread-locals are classified not_a_gc_pointer (counters). census.rs:PASS1_MARKED is re-pinned with dated notes (26f90297e5, 46ff6866d1).

Phase tables

#10220's probe (fullprobe.ts, one 20 MB tree in old gen, then gc() × 6), ms, min of 5

Before and after each change. The steps used intermediate builds and temporary local switches; materialize / sw_* come from temporary timers.

### 0base
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       81.6        8.9        0.5       26.0        0.0       23.9       21.5          -          -          -          -          -        0.5        0.5
    2       47.7        6.8        0.5       26.5        0.0        0.1       13.0          -          -          -          -          -        0.5        0.5
    3       41.7        5.5        0.4       25.9        0.0        0.0        9.3          -          -          -          -          -        0.3        0.3
    4       37.8        4.9        0.4       24.3        0.0        0.1        7.3          -          -          -          -          -        0.3        0.3
    5       38.2        4.9        0.4       24.5        0.0        0.1        7.5          -          -          -          -          -        0.4        0.4
    6       38.2        4.9        0.4       25.0        0.0        0.1        7.3          -          -          -          -          -        0.4        0.3
    7       37.9        4.9        0.4       24.5        0.0        0.1        7.3          -          -          -          -          -        0.4        0.3
### 1m4
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       81.3        9.1        0.5       26.7        0.0       22.7       21.2        0.7       15.8        2.2        1.6        0.0        0.5        0.5
    2       46.3        6.5        0.4       27.0        0.0        0.1       11.0        0.5        7.8        1.8        0.7        0.0        0.4        0.3
    3       41.5        5.6        0.4       26.3        0.0        0.1        8.3        0.4        5.5        1.7        0.4        0.0        0.3        0.3
    4       38.6        5.1        0.4       25.9        0.0        0.1        6.5        0.4        4.1        1.7        0.1        0.0        0.3        0.3
    5       37.9        4.9        0.4       25.3        0.0        0.1        6.3        0.4        4.0        1.7        0.0        0.0        0.3        0.3
    6       37.8        4.9        0.4       25.1        0.0        0.0        6.6        0.4        4.1        1.8        0.0        0.0        0.3        0.3
    7       39.5        5.0        0.4       26.6        0.0        0.1        6.5        0.4        4.0        1.7        0.0        0.0        0.3        0.3
### 2nomat
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       82.3        9.4        0.5       26.9        0.0       23.2       20.9        0.8       15.9        2.1        1.4        0.0        0.5        0.5
    2       47.9        6.4        0.5       27.5        0.0        0.1       11.8        0.6        8.3        1.7        0.7        0.0        0.4        0.4
    3       42.0        5.6        0.4       26.0        0.0        0.1        8.2        0.4        5.5        1.7        0.4        0.0        0.3        0.3
    4       38.6        5.1        0.4       25.7        0.0        0.1        6.7        0.4        4.2        1.7        0.1        0.0        0.3        0.3
    5       38.6        5.1        0.4       25.4        0.0        0.1        6.4        0.4        4.1        1.7        0.0        0.0        0.3        0.3
    6       39.2        5.0        0.4       25.8        0.0        0.0        6.4        0.4        4.0        1.7        0.0        0.0        0.4        0.4
    7       39.1        4.9        0.4       25.2        0.0        0.1        6.4        0.4        4.1        1.7        0.0        0.0        0.3        0.3
### 3tightcensus
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       76.5        4.3        0.5       26.9        0.0       22.8       20.7        0.8       15.9        2.1        1.5        0.0        0.5        0.5
    2       42.7        3.0        0.4       26.8        0.0        0.0       11.5        0.5        8.2        1.8        0.7        0.0        0.4        0.4
    3       38.0        2.5        0.4       26.2        0.0        0.1        8.1        0.4        5.4        1.7        0.4        0.0        0.3        0.3
    4       35.7        2.3        0.4       25.7        0.0        0.1        6.7        0.4        4.2        1.8        0.2        0.0        0.3        0.3
    5       34.8        2.2        0.4       24.9        0.0        0.0        6.6        0.4        4.2        1.8        0.0        0.0        0.3        0.3
    6       36.6        2.2        0.4       26.0        0.0        0.1        6.7        0.4        4.2        1.8        0.0        0.0        0.3        0.3
    7       35.2        2.2        0.4       25.5        0.0        0.1        6.5        0.4        4.1        1.7        0.0        0.0        0.3        0.3
### 4holes
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       74.9        4.2        0.5       27.2        0.0       23.0       18.5        0.8       15.6        0.5        1.4        0.0        0.5        0.5
    2       41.0        3.2        0.4       26.9        0.0        0.0        9.8        0.5        8.1        0.2        0.7        0.0        0.4        0.4
    3       36.5        2.6        0.4       26.1        0.0        0.1        6.5        0.4        5.4        0.2        0.4        0.0        0.3        0.3
    4       34.3        2.3        0.4       26.0        0.0        0.1        5.1        0.4        4.2        0.2        0.1        0.0        0.3        0.3
    5       33.5        2.3        0.4       25.3        0.0        0.0        4.9        0.4        4.1        0.2        0.0        0.0        0.3        0.3
    6       34.2        2.2        0.4       25.6        0.0        0.1        5.2        0.4        4.3        0.2        0.0        0.0        0.3        0.3
    7       34.1        2.3        0.4       25.8        0.0        0.1        5.0        0.4        4.2        0.2        0.0        0.0        0.3        0.3
### 5markhoist
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       69.4        4.2        0.5       25.4        0.0       18.0       20.2        0.7       17.0        0.5        1.5        0.0        0.5        0.5
    2       40.8        3.1        0.5       26.0        0.0        0.1       10.4        0.5        8.8        0.2        0.7        0.0        0.4        0.3
    3       35.5        2.5        0.4       24.8        0.0        0.0        7.3        0.4        6.1        0.2        0.4        0.0        0.3        0.3
    4       31.8        2.2        0.4       23.1        0.0        0.1        5.4        0.4        4.6        0.2        0.1        0.0        0.3        0.3
    5       32.2        2.2        0.4       23.4        0.0        0.1        5.6        0.4        4.8        0.2        0.0        0.0        0.3        0.3
    6       33.1        2.2        0.4       24.3        0.0        0.1        5.4        0.4        4.6        0.2        0.0        0.0        0.3        0.3
    7       32.8        2.3        0.4       23.9        0.0        0.1        5.5        0.4        4.7        0.2        0.0        0.0        0.3        0.3
### 6tightsweep
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       66.0        4.1        0.5       25.0        0.0       17.9       17.6        0.8       14.4        0.5        1.5        0.0        0.5        0.4
    2       38.3        3.0        0.4       25.6        0.0        0.0        8.2        0.5        6.6        0.2        0.7        0.0        0.3        0.3
    3       34.4        2.6        0.4       25.2        0.0        0.1        5.4        0.4        4.2        0.2        0.4        0.0        0.3        0.3
    4       31.5        2.2        0.4       24.6        0.0        0.0        3.8        0.4        2.9        0.2        0.2        0.0        0.3        0.3
    5       30.7        2.2        0.4       23.8        0.0        0.1        3.6        0.4        2.8        0.2        0.0        0.0        0.3        0.3
    6       31.3        2.2        0.4       24.3        0.0        0.1        3.7        0.4        2.9        0.2        0.0        0.0        0.3        0.3
    7       30.4        2.2        0.4       23.6        0.0        0.1        3.6        0.4        2.8        0.2        0.0        0.0        0.4        0.4
### 7layoutctr
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       69.3        4.1        0.5       26.2        0.0       20.3       17.6        0.8       14.2        0.5        1.5        0.0        0.5        0.5
    2       39.4        3.0        0.4       26.9        0.0        0.0        8.3        0.5        6.6        0.2        0.7        0.0        0.4        0.3
    3       34.4        2.5        0.4       25.6        0.0        0.0        5.3        0.4        4.1        0.2        0.4        0.0        0.3        0.3
    4       32.4        2.3        0.4       25.6        0.0        0.0        3.7        0.4        2.8        0.2        0.1        0.0        0.3        0.3
    5       32.3        2.2        0.4       25.3        0.0        0.0        3.5        0.4        2.7        0.2        0.0        0.0        0.4        0.3
    6       32.0        2.2        0.4       25.2        0.0        0.1        3.6        0.4        2.9        0.2        0.0        0.0        0.3        0.3
    7       32.1        2.2        0.4       25.1        0.0        0.0        3.4        0.4        2.7        0.2        0.0        0.0        0.3        0.3
### 8blocklookup
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       68.6        4.3        0.5       24.7        0.0       20.1       17.8        0.7       14.7        0.5        1.4        0.0        0.5        0.5
    2       38.4        3.0        0.4       25.0        0.0        0.0        8.5        0.5        7.0        0.2        0.7        0.0        0.4        0.4
    3       33.2        2.6        0.4       24.2        0.0        0.1        5.4        0.4        4.2        0.2        0.4        0.0        0.3        0.3
    4       31.7        2.2        0.4       24.5        0.0        0.1        3.8        0.4        2.9        0.2        0.1        0.0        0.3        0.3
    5       31.0        2.2        0.4       23.9        0.0        0.0        3.7        0.4        2.9        0.2        0.0        0.0        0.3        0.3
    6       30.7        2.2        0.4       23.9        0.0        0.1        3.6        0.4        2.8        0.2        0.0        0.0        0.3        0.3
    7       31.2        2.3        0.4       24.2        0.0        0.1        3.7        0.4        2.9        0.2        0.0        0.0        0.3        0.3
### 9leafskip
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_trim
    1       64.1        4.1        0.5       20.7        0.0       20.1       17.7        0.7       14.5        0.5        1.4        0.0        0.5        0.5
    2       33.8        3.0        0.4       21.2        0.0        0.1        8.5        0.5        6.7        0.2        0.7        0.0        0.3        0.3
    3       30.1        2.7        0.4       20.9        0.0        0.1        5.4        0.4        4.2        0.2        0.4        0.0        0.2        0.2
    4       27.1        2.3        0.4       19.9        0.0        0.1        3.6        0.4        2.7        0.2        0.1        0.0        0.2        0.2
    5       26.9        2.2        0.4       19.7        0.0        0.1        3.6        0.4        2.8        0.2        0.0        0.0        0.3        0.3
    6       26.5        2.2        0.4       19.2        0.0        0.1        3.7        0.4        2.9        0.2        0.0        0.0        0.3        0.2
    7       26.4        2.2        0.4       19.8        0.0        0.0        3.5        0.4        2.7        0.2        0.0        0.0        0.2        0.2

Final head (90f99d2ce5 runtime) against base, production binaries, PERRY_GC_TRACE phase_us, min of 5.

### probe_base
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_tri
    1       80.2        8.9        0.5       25.6        0.0       23.2       21.0          -          -          -          -          -        0.6        0.6
    2       47.1        6.9        0.4       26.3        0.0        0.0       12.6          -          -          -          -          -        0.5        0.5
    3       41.3        5.4        0.4       25.9        0.0        0.0        8.8          -          -          -          -          -        0.4        0.4
    4       38.5        4.9        0.4       24.7        0.0        0.0        7.2          -          -          -          -          -        0.4        0.4
    5       38.2        4.8        0.4       24.9        0.0        0.0        7.2          -          -          -          -          -        0.4        0.4
    6       37.7        4.9        0.4       24.6        0.0        0.0        7.1          -          -          -          -          -        0.4        0.4
    7       38.1        4.9        0.4       25.0        0.0        0.0        7.2          -          -          -          -          -        0.4        0.4
### probe_head
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_tri
    1       27.3        2.4        0.4       18.9        0.0        0.0        4.4          -          -          -          -          -        0.8        0.8
    2       42.3        5.5        0.4       18.6        0.0        0.0       17.3          -          -          -          -          -        0.2        0.2
    3       26.4        2.8        0.4       18.1        0.0        0.0        4.8          -          -          -          -          -        0.2        0.2
    4       24.8        2.4        0.4       18.3        0.0        0.0        3.4          -          -          -          -          -        0.2        0.2
    5       25.0        2.4        0.3       18.6        0.0        0.0        3.3          -          -          -          -          -        0.2        0.2
    6       24.7        2.4        0.3       18.3        0.0        0.0        3.2          -          -          -          -          -        0.2        0.2
    7       24.8        2.4        0.3       18.4        0.0        0.0        3.3          -          -          -          -          -        0.2        0.2

A real pacing full in records_array_20m:parse (n=4, warm 2)

Base has no full on this row, so "before" is #10220's k=1, floor 16 MB cohort bound as an allocation-point arm (1alloc). Steps are cumulative and use temporary timers, DIAG on, min of 7. atomic is the remembered-set rebuild; ctor is the full's setup, which was mostly run expansion.

### 1alloc: cpu min 277.6 (diag on)  rss min 189  fulls [2, 2, 2, 2, 2, 2, 2]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      9.9      0.6     24.0      0.0     17.4     10.3      0.0      1.1     64.1
     2     15.1      0.5     25.3      0.0      0.1     12.1      1.5      7.2     62.4
### 2safepoint: cpu min 276.2 (diag on)  rss min 194  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1     10.1      0.5     24.1      0.0      0.1     10.0      0.0      1.2     46.3
     2     15.3      0.4     25.1      0.0      0.0     12.1      1.6      7.4     62.4
     3     15.0      0.4     26.0      0.0      0.1     11.4      1.1      7.9     62.2
### 3nomat: cpu min 258.2 (diag on)  rss min 179  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1     10.0      0.5     24.1      0.0      0.1     10.2      0.0      1.1     46.5
     2     15.3      0.4     24.9      0.0      0.1     12.8      1.5      0.0     54.3
     3     15.1      0.4     25.1      0.0      0.1     11.0      0.9      0.0     52.3
### 4tightcensus: cpu min 242.7 (diag on)  rss min 179  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      4.5      0.5     23.6      0.0      0.1     10.2      0.0      1.1     41.1
     2      6.8      0.4     25.1      0.0      0.1     12.6      1.5      0.0     45.6
     3      6.9      0.4     25.1      0.0      0.0     11.3      0.8      0.0     44.5
### 5holes: cpu min 239.3 (diag on)  rss min 179  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      4.4      0.5     23.9      0.0      0.1      8.5      0.0      1.1     38.8
     2      6.9      0.4     24.8      0.0      0.1     10.8      1.5      0.0     43.6
     3      6.7      0.4     25.1      0.0      0.1      9.2      0.9      0.0     41.8
### 6markhoist: cpu min 234.2 (diag on)  rss min 179  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      4.5      0.5     22.2      0.0      0.1      8.9      0.0      1.1     37.7
     2      6.8      0.4     22.4      0.0      0.1     11.3      1.5      0.0     41.7
     3      6.6      0.4     23.0      0.0      0.0     10.0      0.8      0.0     41.0
### 7tightsweep: cpu min 230.5 (diag on)  rss min 179  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      4.5      0.5     21.6      0.0      0.1      7.2      0.0      1.2     35.2
     2      6.8      0.4     22.2      0.0      0.0      9.4      1.5      0.0     39.6
     3      6.7      0.4     22.6      0.0      0.1      8.2      0.8      0.0     39.1
### 8layoutctr: cpu min 229.1 (diag on)  rss min 179  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      4.5      0.5     21.2      0.0      0.1      6.9      0.0      1.1     34.6
     2      6.8      0.4     21.7      0.0      0.1      9.1      1.5      0.0     38.8
     3      6.7      0.4     22.2      0.0      0.0      8.2      0.9      0.0     38.4
### 9blocklookup: cpu min 223.6 (diag on)  rss min 179  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      4.5      0.5     19.4      0.0      0.1      7.0      0.0      1.1     32.8
     2      6.8      0.4     19.7      0.0      0.1      9.7      1.5      0.0     37.6
     3      6.8      0.4     20.0      0.0      0.1      8.1      0.8      0.0     36.2
### 10leafskip: cpu min 216.7 (diag on)  rss min 178  fulls [3, 3, 3, 3, 3, 3, 3]
  full   census    roots     mark  persist   atomic    sweep  reclaim     ctor    total
     1      4.4      0.5     16.2      0.0      0.1      7.5      0.0      1.1     29.9
     2      6.8      0.4     16.0      0.0      0.0      9.4      1.5      0.0     33.3
     3      6.7      0.4     16.5      0.0      0.1      7.9      0.7      0.0     32.4

Final head, production binary, PERRY_GC_TRACE phase_us, min of 5 (TRACE inflates mark):

### worker_base
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_tri
### worker_head
full#      pause     census      roots       mark    persist    rebuild      sweep    sw_prep sw_objects   sw_holes sw_cleanup materialize    reclaim malloc_tri
    1       32.1        4.7        0.5       18.2        0.0        0.1        7.1          -          -          -          -          -        1.3        1.2
    2       31.9        2.6        0.4       19.2        0.0        0.0        8.5          -          -          -          -          -        0.5        0.5
    3       30.4        2.5        0.4       19.1        0.0        0.0        7.6          -          -          -          -          -        0.4        0.4

Root scan. A forced conservative scan costs about 0.5 ms as a phase. What it really cost was #10220's rebuild skip: a conservative root can pin young objects, so the rebuild could not be skipped (1alloc 17.4 ms against 2safepoint 0.1 ms on the first full).

22-row matrix

Interleaved best-of-3 on the same tree, cells_gc.txt (n / warm as listed there).

  • Arms: base 9a05821b9e; head (90f99d2ce5 runtime); "cohort off" is head with promotion_may_reach_bound returning false locally.
  • Load: 9–14 on 10 cores.
  • Repeatability: an earlier full matrix (matrix_final1) agrees within ±3 % on every head row; the exception is records_array_8m:roundtrip, which swung up to 6 % in both arms.
row base CPU ms / RSS MiB head head vs base CPU cohort off best node/bun
records_array_16k:scan 158.1 / 33 158.5 / 32 +0.3 % 158.8 / 33 168.6 / 62
records_array_1m:parse 174.1 / 68 172.4 / 67 -1.0 % 171.5 / 68 416.1 / 92
records_array_1m:roundtrip 183.0 / 61 179.2 / 61 -2.1 % 179.0 / 61 395.7 / 97
records_array_1m:scan 194.7 / 66 196.1 / 66 +0.7 % 195.8 / 66 169.6 / 84
records_array_1m:sparse 173.5 / 68 170.8 / 68 -1.6 % 174.0 / 68 399.0 / 98
records_array_1m:stringify 166.0 / 62 165.1 / 62 -0.5 % 165.3 / 62 210.1 / 105
records_array_20m:parse 143.8 / 240 209.6 / 167 +45.8 % 146.4 / 240 208.0 / 220
records_array_20m:roundtrip 105.0 / 260 117.2 / 231 +11.6 % 105.2 / 260 162.1 / 261
records_array_20m:scan 153.7 / 240 218.4 / 167 +42.1 % 154.6 / 240 212.0 / 225
records_array_20m:sparse 145.5 / 240 214.7 / 167 +47.6 % 147.1 / 240 208.4 / 220
records_array_20m:stringify 156.8 / 198 156.2 / 198 -0.4 % 158.1 / 198 219.4 / 373
records_array_8m:parse 141.5 / 109 144.0 / 97 +1.8 % 145.1 / 97 375.0 / 170
records_array_8m:roundtrip 159.7 / 129 157.5 / 129 -1.4 % 157.5 / 129 343.1 / 182
records_array_8m:scan 182.0 / 189 227.1 / 127 +24.8 % 183.0 / 189 188.7 / 110
records_array_8m:sparse 147.4 / 109 144.7 / 98 -1.8 % 145.9 / 98 345.0 / 198
records_array_8m:stringify 154.4 / 123 153.8 / 123 -0.4 % 153.9 / 123 222.1 / 197
records_object_20m:parse 146.3 / 240 210.9 / 167 +44.2 % 148.3 / 240 207.4 / 220
records_object_20m:stringify 157.7 / 198 159.2 / 198 +1.0 % 158.1 / 198 219.6 / 373
records_object_8m:parse 190.1 / 118 191.3 / 118 +0.6 % 195.0 / 118 234.9 / 112
records_object_8m:stringify 157.3 / 123 156.9 / 123 -0.3 % 159.5 / 123 223.9 / 197
small_record:parse 171.6 / 80 168.9 / 79 -1.6 % 173.2 / 80 409.0 / 60
wide_1m:parse 182.9 / 86 180.1 / 86 -1.5 % 180.4 / 86 306.0 / 96
row base regime head regime
records_array_16k:scan minors=7 (untraced 0) fulls=[] minors=7 (untraced 0) fulls=[]
records_array_1m:parse minors=0 (untraced 0) fulls=[8×alloc_point_old_reclaim ] minors=0 (untraced 0) fulls=[8×alloc_point_old_reclaim ]
records_array_1m:roundtrip minors=0 (untraced 0) fulls=[8×alloc_point_old_reclaim ] minors=0 (untraced 0) fulls=[8×alloc_point_old_reclaim ]
records_array_1m:scan minors=4 (untraced 0) fulls=[] minors=4 (untraced 0) fulls=[]
records_array_1m:sparse minors=0 (untraced 0) fulls=[7×alloc_point_old_reclaim ] minors=0 (untraced 0) fulls=[7×alloc_point_old_reclaim ]
records_array_1m:stringify minors=5 (untraced 0) fulls=[] minors=5 (untraced 0) fulls=[]
records_array_20m:parse minors=2 (untraced 1) fulls=[] minors=3 (untraced 2) fulls=[3×safepoint_promoted_cohort ] cohort productive=3 futile=0 adopted_blocks=112
records_array_20m:roundtrip minors=2 (untraced 1) fulls=[] minors=3 (untraced 2) fulls=[2×safepoint_promoted_cohort ] cohort productive=2 futile=0 adopted_blocks=56
records_array_20m:scan minors=2 (untraced 1) fulls=[] minors=3 (untraced 2) fulls=[3×safepoint_promoted_cohort ] cohort productive=3 futile=0 adopted_blocks=112
records_array_20m:sparse minors=2 (untraced 1) fulls=[] minors=3 (untraced 2) fulls=[3×safepoint_promoted_cohort ] cohort productive=3 futile=0 adopted_blocks=112
records_array_20m:stringify minors=7 (untraced 0) fulls=[1×alloc_point_old_reclaim ] minors=7 (untraced 0) fulls=[1×alloc_point_old_reclaim ]
records_array_8m:parse minors=0 (untraced 0) fulls=[6×alloc_point_old_reclaim ] minors=0 (untraced 0) fulls=[8×alloc_point_old_reclaim ]
records_array_8m:roundtrip minors=0 (untraced 0) fulls=[11×alloc_point_old_reclaim ] minors=0 (untraced 0) fulls=[11×alloc_point_old_reclaim ]
records_array_8m:scan minors=3 (untraced 2) fulls=[] minors=4 (untraced 3) fulls=[4×safepoint_promoted_cohort ] cohort productive=4 futile=0 adopted_blocks=96
records_array_8m:sparse minors=0 (untraced 0) fulls=[6×alloc_point_old_reclaim ] minors=0 (untraced 0) fulls=[7×alloc_point_old_reclaim ]
records_array_8m:stringify minors=8 (untraced 0) fulls=[] minors=8 (untraced 0) fulls=[]
records_object_20m:parse minors=2 (untraced 1) fulls=[] minors=3 (untraced 2) fulls=[3×safepoint_promoted_cohort ] cohort productive=3 futile=0 adopted_blocks=112
records_object_20m:stringify minors=7 (untraced 0) fulls=[1×alloc_point_old_reclaim ] minors=7 (untraced 0) fulls=[1×alloc_point_old_reclaim ]
records_object_8m:parse minors=5 (untraced 0) fulls=[] minors=5 (untraced 0) fulls=[]
records_object_8m:stringify minors=8 (untraced 0) fulls=[] minors=8 (untraced 0) fulls=[]
small_record:parse minors=3 (untraced 0) fulls=[] minors=3 (untraced 0) fulls=[]
wide_1m:parse minors=5 (untraced 0) fulls=[] minors=5 (untraced 0) fulls=[]

Misses

  • Target rows, CPU 0.8–3.0 % over best.
    • Each 4-iteration run makes three minors and three cohort fulls. Base makes two minors and no full.
    • The fulls run once the loop's promotions pass the bound. Each minor promotes the live tree and also its dead predecessor, which the born-old top-level array keeps reachable through remembered slots.
    • Three fulls cost 62–66 ms of CPU, about 22 ms each, measured as (head − base) / 3. The base lead over node/bun is 58–64 ms.
    • A bound that runs two fulls would fit, but that is tuning to fit, which the brief rules out.
  • records_array_8m:scan, 227.1 ms against best 188.7 (base 182.0). Four cohort fulls on an 8-iteration row with 6.7 ms of headroom. No bound that reclaims the 20 MB cohorts spares this row: it promotes the same shape at 8 MB, and one full costs more than its headroom (as gc: make a synchronous full cheaper per live object (#10182) [pacing retry does not meet acceptance] #10220 already noted).
  • records_array_20m:roundtrip, +11.6 % CPU (two cohort fulls). It is still under the best (117 against 162) and saves 29 MiB.
  • Cohort off (mechanism commits only). No row is outside ±2 % CPU in both matrices; the 20 MB rows read +0.0 to +1.8 %. records_array_8m:parse/sparse peak RSS falls from 109 to 97/98 MiB.
    • Base retained about 470 KB more dead old data per full (old_in_use 8.56 → 9.98 MB over six fulls); head stays flat at 9.04 MB.
    • That shifts the old-reclaim fulls from 6 to 8 (parse) and 6 to 7 (sparse) at unchanged CPU.
  • Dead-stack scrub. Before it, the one-pass census left one extra conservative root in its dead frame. records_array_8m:roundtrip then kept a dead 7 MB string and its hole: 129 → 159 MiB, deterministic, bisected to 31b07d915f. With the scrub the root counts match base again, at 129 MiB.

gc-ratchet, base against head

gc_ratchet.py measure --repeats 7, PERRY_NO_AUTO_OPTIMIZE=1, own PERRY_RUNTIME_DIR per arm.

  • Against the committed baseline: base has 30 red rows (main's existing shared_ci rows); head has those same 30 plus 7 new ones, all in 14_grow_then_churn.
  • Base against head: the other 13 probes have identical gated counters.
  - 14_grow_then_churn: minor_cycles 21 -> 28 (+33.33%), allowance 1 [either]
  - 14_grow_then_churn: step_cycles 22 -> 29 (+31.82%), allowance 1 [either]
  - 14_grow_then_churn: copied_objects 330 -> 0 (-100.00%), allowance 16 [either]
  - 14_grow_then_churn: copied_bytes 1,222,176 -> 0 (-100.00%), allowance 65,536 [either]
  - 14_grow_then_churn: promoted_objects 404,022 -> 545,345 (+34.98%), allowance 20,201 [either]
  - 14_grow_then_churn: promoted_bytes 147,516,064 -> 337,721,928 (+128.94%), allowance 7,375,803 [either]
  - 14_grow_then_churn: freed_bytes 118,903,288 -> 0 (-100.00%), allowance 5,945,164 [either]

Why 14_grow_then_churn changes.

  • Base: it promotes 227 MB of churn untraced and reclaims it in one alloc_point_old_reclaim full. After that the heap is small, so 15 of the remaining minors copy (330 objects) and free from-space; that is the evacuating minor's freed_bytes.
  • Head: it runs 13 cohort fulls, keeping old gen near its live set. The first two are futile, so shift reaches 2; the other 11 are productive.
    • Every minor is then an untraced in-place promotion (26 of 28), so copied_* and the minor's own freed_bytes go to 0 and promoted_* rises.
    • The reclaim happens in the fulls, which this counter does not see.
    • The extra minors come from the smaller retained heap.

Ungated metrics (medians of 7):

probe metric base head
14_grow_then_churn peak RSS 298.4 MB 137.7 MB (0.46×)
14_grow_then_churn heap_used 0.58×
14_grow_then_churn wall 429 ms 594 ms (+38 %)
12_large_live_set peak RSS 114.4 MB 121.2 MB (+6 %)
12_large_live_set wall 247 ms 275 ms (+11 %)
  • 12_large_live_set runs one futile cohort full (cohort 21.2 MB, reclaimed 8.3 MB) with no adopted blocks, so it pays a full census.
  • Both are regressions of the pacing commits.
  • All other probes are within ±0.5 % peak RSS. They are within ±5 % wall except 09_try_catch_roots (0.88×), 03_cross_gen_writes (0.94×) and 04_dead_after_deep_stack (0.95×), all faster.

Tests and gates

RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --lib (at 90f99d2ce5; 46ff6866d1 and the changelog commit change no Rust):

failures:
    gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds

test result: FAILED. 3789 passed; 1 failed; 4 ignored; 0 measured; 0 filtered out; finished in 12.49s

That test asserts a debug_assertions panic and fails under --release on base too. Every new test above passes, including every sabotaged twin.

SKIP_COMPILE_GATES=1 scripts/run_lint_gates.sh at 46ff6866d1:

run_lint_gates: 1 of 77 FAILED (compile tier SKIPPED); 2 CI-only skipped
  [Public benchmark evidence freshness] python3 benchmarks/ci_public_baseline_check.py

Compile tier, run step by step:

step result
RUSTFLAGS="-D warnings" cargo check -p perry --bins ok
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets … red: could not compile perry-runtime (lib) due to 2 previous errors / (lib test) due to 3 previous errors, all at object/global_this_webassembly.rs:192/201/662, as on main. Without -D warnings, perry-runtime all-targets reports only those three warnings.
cargo clippy -p perry --bins ok
cargo clippy --workspace … ok
./scripts/regen_api_docs.sh + drift check red: docs/api/perry.d.ts / docs/src/api/reference.md drift, as on main; both files restored

That is main's three reds and nothing else.

python3 scripts/gc_runtime_root_holders.py:

gc_runtime_root_holders self-test: OK (90 planted declarations classified, 391 inventory entries checked)
gc_runtime_root_holders: OK — 1438 holder declarations scanned (1225 identity-ratcheted), 630 reached by a registered scanner, 391 classified in the inventory, 412 pinned on the frontier ratchet (155 registered scanners).

Seeded stress on worker cells. PERRY_GC_SCHEDULE_SEED=1..4 PERRY_GC_SCHEDULE_RATE=0.2 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=32, plus an unseeded seed-0 control, with the worker's verify mode.

  • Cells (10): 1m scan/roundtrip, 8m scan/parse/sparse, object_8m parse, 20m parse/roundtrip/scan, object_20m parse.
  • Result: 50 of 50 runs exit 0. Checksum and verify-output hash are identical across all five seeds of each cell.
  • Live: 9 of the 10 cells quarantined 1–6 retired sets per seeded run, and block-skip reclaims and rebuild skips fired on the 20 MB rows.
  • Not covered: records_array_8m:sparse makes no copying minor at n=2, so the quarantine protected nothing on that cell.

Seeded stress on test_gap_gc* (same knobs, seeds 1–4, compared with the unseeded perry output and node):

  • Result: 43 of 44 runnable tests are byte-identical to node on every seed, with retired sets > 0 on every test.
  • Pre-existing fault, identical on base. test_gap_gc_staging_args_rooting seed 4 dies with SIGBUS: a stale use of retired from-space at minor #0 (obj_type=2 size=40). The base binary built from 9a05821b9e faults the same way, 3 of 3 runs.
  • Not built by this hand harness (base too): 6 tests — call_argument_rooting, http2_pending_event_callback_rooting, net_once_flags_rekey, next_request_import and rest_argument_rooting (undefined symbols against a bare runtime dir), and namespace_and_computed_dispatch_rooting (relative fixture import).
  • Skipped: test_gap_gc_alloc_point_no_move (test_gap_gc_alloc_point_no_move.ts does not finish compiling on main (>23 min); #7682 coverage may be dark #8906).

PERRY_GC_VERIFY_EVACUATION=1 on all 22 worker cells, seeds 1 and 2 at rate 0.2, verify mode:

  • 44 of 44 runs exit 0 with no panic.
  • Every copying minor printed [gc-verify] … evacuation_ok (verify lines = minors, 1–972 per run).
  • Cohort fulls ran on the 20 MB rows and records_array_8m:scan.

Premises in the brief that did not hold

  1. "Two fulls fit the lead."
    • At the precise safepoint, the cohort full fires three times per target run, not two. Moving the trigger from the allocation point to the safepoint changed the count from 2 to 3 (1alloc against 2safepoint), and the head regime has one more minor than base.
    • At about 22 ms net per full, three fulls use up the whole ≈62 ms lead.
  2. "All other rows within ±2 % CPU" is incompatible with any cohort bound that reclaims the 20 MB rows.
    • records_array_8m:scan has the same shape at 8 MB: four fulls with 6.7 ms of headroom.
    • The brief scopes that row out of effort, but its acceptance line still covers it.
  3. "Census over the dead promoted trees." Most of the census cost was the per-object cursor, not the dead fraction. A one-pass per-block parse that still reads every header halved it (probe 8.9 → 4.3 ms). Adoption then removed the promoted blocks from the census; no liveness test was needed.
  4. "Forced conservative stack scan … check which phases it costs." The scan itself is ~0.5 ms. Its cost was disabling gc: make a synchronous full cheaper per live object (#10182) [pacing retry does not meet acceptance] #10220's rebuild skip (17 ms).
  5. The 179 MiB / 62–68 ms figures were gc: make a synchronous full cheaper per live object (#10182) [pacing retry does not meet acceptance] #10220's k=1, floor 16 MB arm at an allocation point. The bound here reaches 167 MiB at 30–32 ms per full.

Process notes

  • Disk guard. Two incremental builds (c3 and h1) started with 4 GB free, below the 5 GB guard. Both completed normally.
  • Background call. One Bash call with a 900 s timeout was moved to the background; it completed and its file state was verified.
  • Stray file. One diagnostic run wrote /tmp/x.err.

@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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 proggeramlug changed the title gc: a pacing full at roughly the cost of its mark; promoted-cohort pacing (#10182) [does not meet acceptance] gc: a pacing full at about the cost of its live mark; promoted-cohort fulls (#10182) [does not meet acceptance] Sep 14, 2026
Ralph Küpper added 19 commits September 14, 2026 06:16
…es it (#10182)

A synchronous full used to expand every pending promoted page run in its
constructor, including the runs of blocks its block-granular sweep then
released whole: 7-8 ms of a 62 ms pacing full on records_array_20m:parse and
~18 MiB of transient page lists. The sweep now expands a page right before it
invalidates the first dead header on it (PendingOldUnregister::defer), the
same order invalidate_dead_old_arena_header already keeps; a page on which
every object survives keeps its run, and a block reclaimed whole discards its
runs unexpanded.
The exact census reads every header in the arena. On a pacing full over two
promoted 20 MB JSON trees it spent ~15 ms doing it, against ~4 ms for a plain
header walk over the same 1.8 M objects: the cost was the per-object cursor
call and the per-object recomputation of block constants, not the memory
traffic. ValidPointerSetBuilder::census_whole_block parses a block itself
(same alignment, stop conditions and walkability filter as next_budgeted),
sets start bits through the block's chunk pointers, and applies the pointer
range, start count and nursery classification once per block. Budgeted steps
and classifier-mode sets keep the per-object walk.
…le-free (#10182)

After the object walk, old_free_rebuild_from_live_old_blocks re-parsed every
live old block looking for invalidated headers: on a pacing full that keeps
one promoted 20 MB JSON tree, a second pass over the whole tree that finds no
hole. The whole-block census now records whether a block holds any header
that does not parse as an object, and the sweep records where it invalidated
one; the rebuild skips a live old block that is hole-free by both, unchanged
since the census. Counter: hole_rebuild_blocks_skipped; diag
[gc-old-free] rebuild_skipped_blocks=.
A full's sweep called the arena cursor once per object and re-derived the
object's block constants (old or general, from-space membership, age
bumping) for each one. On a pacing full that keeps one promoted 20 MB JSON
tree that was the sweep's largest cost after block skipping. The unbudgeted
sweep now parses each block itself, keeps a marked, unpinned, unforwarded
object inline with the block constants hoisted, and hands every other header
to process_object unchanged. Budgeted sweeps keep the per-object cursor.
The full mark paid, for every traced slot: a page-generation lookup to build
GcMutableSlot::external (read only by the copying minor), a thread-local read
for the proxy-observation flag, a header re-read to rule out weak-holder
classes, a dynamic callback per slot of a range descriptor, and — because the
optimizer hoisted it above the arm check — a thread-local address fetch for
the layout-scan counters. The slot's generation is now classified when
asked, the proxy and weak-holder facts are read once per object, range
descriptors are walked inline, and the layout counter's thread-local lives
out of line behind the process-wide arm flag. classifier_verify_enabled tests
its cached static before its thread-local.
Every membership query of the full mark first binary-searched the census
block fences (about 150 on a 20 MB JSON pacing full), and after the per-slot
hoists that search was the largest single cost left in the mark. The
finished census now also builds a table with one entry per 1 MiB window:
the greatest block base at or below the window start and the one base, if
any, inside it. Arena blocks are at least 1 MiB and never overlap, so a
lookup is a shift, a bounds check and one compare. Sets whose bases share a
window, or that span more than 16 GiB, keep the binary search.
mark_field_into_worklist queued every newly marked object, and the drain
then dispatched each one only to find a leaf descriptor with no slot to
visit. Strings are half the objects of a parsed JSON tree. A pointer-free
object that is not a forwarding stub is now marked and not queued; a
forwarded one is still queued so its hop is followed.
The copying minor's drain prefetches the header a few worklist entries
ahead; the full mark's drain did not, and on a promoted 20 MB JSON tree each
header it dequeues is a cold DRAM read. A prefetch has no architectural
effect and cannot fault.
…moting safepoint (#10182)

Old-reclaim pacing cannot see a promoted tree that dies after its minor:
every promotion credits the growth baseline (#7592/#7965). A document
parse/scan loop lives in that blind spot, because each result's top-level
array is born old and keeps its young contents reachable through remembered
slots until a full proves it dead; on records_array_20m:parse every minor
promotes two trees, one of them dead, and nothing ever collects them.

A full is now due when the bytes promoted since the last full reach
max(one base nursery, the old-gen live set that full verified << backoff).
It is consulted only right after a nursery minor at a precise safepoint, so
the full runs with precise roots and, after an in-place promotion, an empty
young generation. It is not an arm of old_reclaim_pressure_due, whose credited
baseline and the two tests #10204 broke are untouched. A cohort full that
reclaims less than half its cohort doubles the bound (at most three times),
so a heap whose promoted bytes stay live pays a logarithmic number of futile
fulls; a productive one restores it.

Diag: [gc-trigger] promoted_since_full= cohort_bound=, site
safepoint_promoted_cohort, [gc-promoted-cohort] full cohort= bound=
reclaimed= productive= backoff_shift=.
…just promoted (#10182)

The cohort full runs at the same safepoint as the nursery minor whose
promotion made it due, and that minor's untraced in-place promotion has just
parsed every header of every block it promoted. The full's census parsed
them again before the mutator ran a single instruction; on
records_array_20m:parse those blocks are two of the three trees the census
reads.

When the minor's promotion can bring the cohort to its bound, the promotion
walk now records for each block it parses whole the census's start bitmap
and per-block facts, the flag facts computed by the census's own function
after the promotion's own header writes. Only the cohort full started at the
same safepoint may adopt a record, only for a block with the same address,
bump offset and size, and every record is discarded when the safepoint
returns. Test builds re-walk every adopted block with the census walk and
assert the record agrees.
…e the sweep's page tally (#10182)

A range descriptor (an array's elements, an all-pointer field range) is now
walked twice: the first pass starts the header read of every pointer-tagged
slot's target, the second marks. account_old_object is inlined into the
whole-block sweep's live path.
…the liveness vector (#10182)

The hole-free skip copied block_has_live and kept a second per-block vector
for the sweep's invalidations. The rebuild now takes a block filter, and an
invalidation clears the block's hole-free fact directly.
…he root scan (#10182)

A conservative stack scan reads every word from its own stack pointer up,
including slots of live frames that no call has written since a deeper
frame returned. The one-pass census left a different heap address there
than the per-object census did: on records_array_8m:roundtrip every
alloc-point full found one more conservative root (8 against 7), kept a
dead 7 MB stringify result, lost the 7 MB hole it would have left, and peak
RSS rose from 129 to 159 MiB. A full now zeroes 16 KiB of dead stack after
building its census; the root counts and the RSS are back to 129 MiB.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Rebased onto the rebased #10220 (19 commits re-applied cleanly). Authoritative re-measurement on the quiet bench mini instead of the loaded laptop, all 50 JSON matrix rows, best of 3 interleaved rounds per engine against Node 26.5.1 and Bun 1.3.14, with the comparison engines measured in the same run (the best.txt figures the description compares against were stale — e.g. Bun's records_array_8m:scan peak is 126–133 MiB, not 110):

rows main this stack (#10220 + #10241) this stack + #10249
20 MB parse / sparse / scan, records_object_20m:parse CPU 0.64–0.68×, RSS 1.08–1.09× CPU 0.93–0.97×, RSS 0.75–0.77× same
records_array_20m:roundtrip CPU 0.63×, RSS 1.02× CPU 0.72×, RSS 0.91× same
records_array_8m:scan CPU 1.00×, RSS 1.42× CPU 1.26×, RSS 0.94× CPU 0.96×, RSS 0.83×
misses across all 50 rows 10 5 2 (the two tiny-parse rows, addressed separately)

So on the authoritative host the target rows the description reports as missing at +0.8–3.0 % CPU are in fact ahead (0.93–0.97×). The one real miss this stack introduces is records_array_8m:scan CPU (1.26×), and it disappears once #10249 lands: that row was paying for the lazy tape and an eager materialization on every parse because the element-shape clone never fed traversal feedback, and with that fixed the cohort fulls fit inside its CPU lead. Land order: #10249 first, then #10220, then this.

Still open before this can leave draft: the ungated gc-ratchet wall regressions the description lists (14_grow_then_churn 1.38× wall with peak RSS 0.46×, 12_large_live_set one futile cohort full, 1.11× wall / 1.06× RSS). They trade compute for RSS on those probes, which is the trade this repository's GC policy does not accept, so they need either a mechanism fix or an explicit decision.

@proggeramlug proggeramlug changed the title gc: a pacing full at about the cost of its live mark; promoted-cohort fulls (#10182) [does not meet acceptance] gc: a pacing full at about the cost of its live mark; promoted-cohort fulls (#10182) [land after #10249; ratchet wall regressions open] Sep 14, 2026
…oted (#10182)

A promoted-cohort full resets the untraced-promotion budget, so a workload
that turns from building a live set to churning never re-measures: every
minor promotes the churn untraced and every cohort full marks the whole
live set to reclaim it (14_grow_then_churn: 13 cohort fulls, 0 copied).

The full's sweep now measures, block by block, how much of what the minor
at the same safepoint promoted is still reachable (the blocks the
promotion walk recorded for census adoption). Below the in-place promotion
threshold that figure replaces the young-survival predictor, so the next
minor evacuates and measures instead of promoting on faith. A parse loop
measures ~1000 there (the last minor promoted the tree being parsed) and
keeps its untraced promotions.
Ralph Küpper added 7 commits September 14, 2026 07:33
…10182)

records_array_20m:parse measures 500 permille over the blocks its last
minor promoted, not ~1000: that minor promotes the dead previous tree with
the live current one, because the dead tree's born-old top array keeps its
records reachable through remembered slots. Fed to the predictor, that made
the next minor evacuate and copy both trees (~95 ms, +50 MB RSS).

A minor measures what it reaches from roots and from every old object on
its dirty pages, so the full's survival equals the minor's exactly when no
object on those pages that the full's mark left unmarked holds a dirty
slot into the promoted blocks. The minor now notes its remembered set (page
keys only) while the promotion census records; the full checks those
parents after its mark, before its sweep, and the predictor is fed only
when the view is exact.
A copying minor tenures an object only after it survived a minor, so a
cohort full scheduled for tenured bytes is futile by the only measurement a
minor has. 12_large_live_set's one cohort full was reached by 21.2 MB of
copy-tenured bytes over a 16 MB bound and reclaimed 8.3 MB; every cohort
full on the JSON rows was reached by in-place promotions alone. The
old-reclaim baseline credit still takes every promoted byte.
@proggeramlug proggeramlug changed the title gc: a pacing full at about the cost of its live mark; promoted-cohort fulls (#10182) [land after #10249; ratchet wall regressions open] gc: a pacing full at about the cost of its live mark; promoted-cohort fulls (#10182) [land after #10249] Sep 14, 2026
@proggeramlug
proggeramlug marked this pull request as ready for review September 14, 2026 08:20
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed through merge train #10267 (v0.5.1568). The merged main tree matches the validated train, and a fresh patch-ID audit confirms all source changes are included. The broader GC tracker #10182 remains open.

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