Skip to content

fix(gc): denominate the nursery constant band in objects (#7929) - #7961

Merged
proggeramlug merged 3 commits into
mainfrom
gc/7929-object-denominated-nursery-band
Aug 12, 2026
Merged

fix(gc): denominate the nursery constant band in objects (#7929)#7961
proggeramlug merged 3 commits into
mainfrom
gc/7929-object-denominated-nursery-band

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7929.

The mismatch

The scavenge nursery trigger compares from-space bytes against a constant
16 MB band (young_scavenge_cap_due), while the copying minor's cost is per
object. Shrinking a representation therefore silently buys the collector more
work per cycle. #7928 took a two-field object literal 72 B → 56 B and every minor
then moved 1.286× (= 72/56) as many objects for the same bytes, costing
deeplist +10.5% and retain1 +8.8% wall on the quiet mini.

This scales the constant band by the mean size of the objects the last copying
minor actually moved, so the band buys a constant object budget.

Why this is a single comparison

Of the four byte-denominated valves, the two tenuring.rs ratios are
representation-invariant by cancellation — desired_survivor_bytes is cap/16
and retune_nursery_cap_scale compares eden against cap/25, both of which scale
with the object size on both sides. The tenured-proportional arm of the cap is
invariant for the same reason: tenured_bytes / 2 is tenured_objects / 2
objects. Only the constant band converts a fixed byte budget into a variable
object count, so only the constant band is re-denominated here.

The measurement that unblocked it

#7929's recorded blocker was that an object term fires the collector earlier and
every extra cycle pays a fixed root scan. Both sides are now priced, on the shipped
corpus, by sweeping the existing PERRY_GC_SCAVENGE_NURSERY_MB dial (instructions
retired; the dev box ran at load 77–92, so no wall clock is quoted):

  • Marginal cost per object moved: ~1050 instructions. Three deeplist cells
    with an identical cycle-kind profile (2 minors, 0 fulls) — 805 137 objects at
    1203 M, 954 929 at 1366 M, 1 254 513 at 1669 M — give slopes of 1088 and 1011
    instr/object.
  • Fixed cost per extra copying minor: ~0.5 M instructions. churn at caps
    8/16/32 MB runs 172/86/43 minors for 3205.7/3093.3/3078.0 M instructions;
    netting out the object term leaves ~0.49 M per cycle. That is consistent with
    gc: asyncpipe collects at 1200-1650 ns/object, including a 122 ms minor that handled zero objects #7915's own number (218 455 pointer roots at ~2 instructions each).

So an extra minor pays for itself if it avoids ≥ ~500 object-moves, and on the
taxed programs the trade is ~500:1 in favour. On cycles and pipeline more
cycles is outright cheaper (1763.6 M at 23 minors vs 1837.6 M at 6).

One-sided by design

The factor is clamped at 1.0, so a representation larger than the reference gets
today's band rather than a proportionally larger one. Two measured reasons:

Every program at or above the reference is left bit-identical, which is also what
makes the A/B below self-calibrating.

A/B — per program, both arms, cycle kinds included

origin/main @ ac52a5c38 vs this branch, same target dir, same -p set,
PERRY_RUNTIME_DIR pinned, .a mtimes verified to have moved. All 19 programs
byte-identical to the base arm and to node --experimental-strip-types 26.5.1,
exit 0
.

program minors b→f fulls b→f objects moved instructions peak RSS
deeplist 3→3 1→0 −11.8% −72.3% −27.2%
retain 6→5 2→1 −24.5% −62.9% −26.4%
retain1 3→3 1→1 −11.8% −11.3% −6.5%
churn / churn_alloc / push_cls 86→88 0→0 −7.2% −0.9…−1.1% ±0.3%
cycles 12→22 0→0 0 −0.8% +0.1%
pipeline 6→7 0→0 +1.6% −0.9% −0.3%
tree 40→40 0→0 0 −0.2% −0.1%
tree_wide 40→40 0→0 0 −0.1% 0.0%
shapes 1→1 0→0 0 −3.2% 0.0%
asyncpipe 1→1 0→0 0 −2.5% +0.3%
interp 31→44 0→0 +3.7% +0.2% +0.1%
iso_miss 43→61 0→0 +7.4% +0.4% −0.1%
push_num 18→18 0→0 0 −2.1% +0.2%
retain_wide 7→7 2→2 0 −0.3% 0.0%
retain_wide1 4→4 1→1 0 −0.8% −0.1%
churn_read 0→0 0→0 0 −3.5% −0.9%
fib40 0→0 0→0 0 −0.3% +1.4%

Italic rows are the control set: their factor is exactly 1.000 (clamped, or no
copying minor ever ran), so their policy is bit-identical and their spread —
−0.3% to −3.5% — is this A/B's link-layout noise floor, not an effect.
interp +0.2% and iso_miss +0.4% sit inside it; the three large deltas do not.
retain1 is the clean cell: identical cycle kinds in both arms, −11.8% object
work for −11.3% instructions, against the +12.0% instructions #7928 cost it.

deeplist's and retain's larger wins are a threshold crossing on top of the
object effect: both were running an old_gen_bytes-triggered full whose
sweep.freed_bytes is 0 — a futile mark-sweep over a heap that is live by
construction — and the smaller band keeps old-gen under the 48 MB one-shot arm of
old_reclaim_pressure_due. Isolating that (interpolating deeplist's 3-minor
cells) leaves the object-denomination effect alone at −10.5%, which is exactly
the +10.5% regression #7928 caused on that program.

Gate

Four tests. The two pure-function tests still pass with the copying.rs call site
deleted — the #7024 shape — so the wiring is covered separately by a band-level
test and by copying_minor_feeds_the_object_denomination_census, which drives a
real copying minor.

★ The discriminating quantity in that test is "the recorded mean equals THIS
cycle's measured mean AND differs from the seed"
. A "the mean is nonzero"
assertion is satisfied by the seed itself, so an unwired build would pass it — a
presence check, not a proof. The test also refuses to run vacuously: it asserts
the fixture's mean is strictly below the reference, so a future representation
change that made the fixture 72 B fails the test rather than silently exercising
the clamp instead of the scaling arm.

Both sabotage arms were run, not argued:

sabotage result
delete the note_surviving_object_census call in copying.rs end-to-end test FAILS (the census must carry THIS cycle's measured mean (19040 B over 340 objects))
delete the factor from influx_driven_nursery_cap_bytes band-level test and end-to-end test FAIL

constant_band_buys_a_constant_object_count additionally carries an inline
sabotage arm: it asserts the un-denominated band inflates the object budget by
≥1250 permille, so if that ever reads 1000 the test declares its own main
assertion meaningless instead of passing quietly.

Validation

  • Corpus 19/19 byte-exact vs node 26.5.1, exit 0, in the ordinary run and
    again under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_VERIFY_EVACUATION=1.
  • iso_miss canary: checksum 437840 misses 0.
  • cargo test --release -p perry-runtime: 2216 passed, 0 failed.
  • cargo fmt --all -- --check, scripts/check_file_size.sh,
    scripts/check_gc_doc_claims.py (17 facts re-derived),
    scripts/gc_runtime_root_holders.py: all clean.
  • No new env knob (CLAUDE.md GC knob kill-policy). Diagnosis rides the existing
    PERRY_GC_DIAG path. Nothing is added to the allocation fast path — the mean
    comes from the census the collector already produces.

Full working: gc-handoff/BUDGET-NOTES.md.

Summary by CodeRabbit

  • New Features

    • Nursery capacity now adapts to the average size of objects moved during garbage collection.
    • Capacity adjustments preserve existing tenuring behavior while improving consistency of object budgets.
    • Added diagnostics for measured object sizes and adaptive capacity scaling.
  • Bug Fixes

    • Empty collection cycles now retain the previous valid measurement.
  • Tests

    • Added coverage for scaling limits, measurement carry-forward, proportional capacity behavior, and end-to-end updates.
  • Documentation

    • Added changelog details, safety rationale, and benchmark results.

The scavenge nursery trigger compares from-space BYTES against a constant
16 MB band, while the copying minor's cost is per OBJECT. Shrinking a
representation therefore silently buys the collector more work per cycle:
#7928 took a two-field object literal 72 B -> 56 B and every minor then
moved 1.286x (= 72/56) as many objects for the same bytes.

Scale the constant band by the mean size of the objects the last copying
minor actually moved, so the band buys a constant OBJECT budget. The mean
comes from the census the collector already produces, so nothing is added
to the allocation fast path.

The scaling is one-sided (clamped at 1.0): a mean above the reference keeps
today's band. That is what neutralises an array-dominated mean, and it
leaves every program at or above the reference bit-identical.

The two tenuring ratios are representation-invariant by cancellation, so
only the constant band is re-denominated.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 693f9a2f-fbc7-43d4-8666-4627df8a3d55

📥 Commits

Reviewing files that changed from the base of the PR and between 2b05723 and 6f38b9a.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/gc/tenuring.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/gc/tenuring.rs

📝 Walkthrough

Walkthrough

The copying minor now reports moved-object census data. Tenuring retains the measured mean object size and scales only the constant nursery-cap band against a 72-byte reference. Unit and integration tests validate scaling, clamping, carry-forward, and proportional-cap isolation.

Changes

GC object-denominated nursery capacity

Layer / File(s) Summary
Adaptive nursery-cap calculation
crates/perry-runtime/src/gc/tenuring.rs
Tenuring records the previous surviving-object mean, scales the constant cap band with 50% lower clamping, preserves the proportional cap term, and resets the measurement state.
Survival census wiring
crates/perry-runtime/src/gc/copying.rs
The copying minor reports combined moved bytes and object counts before tenuring retuning.
Integration validation and changelog
crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs, changelog.d/7961-gc-object-denominated-nursery-band.md
Tests validate current-cycle mean recording and proportional capacity scaling. The changelog documents the new behavior and coverage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CopyingMinor
  participant Tenuring
  participant NurseryCapacity
  CopyingMinor->>Tenuring: report moved bytes and moved objects
  Tenuring->>NurseryCapacity: apply measured object-size scale
  NurseryCapacity-->>Tenuring: return adjusted nursery capacity
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main GC change: denominating the nursery constant band in objects.
Description check ✅ Passed The description provides the change rationale, linked issue, implementation details, extensive validation results, and test coverage, despite not following the template headings.
Linked Issues check ✅ Passed The implementation addresses #7929 by pacing the constant nursery band using measured object size while preserving representation-invariant terms and validating the wiring.
Out of Scope Changes check ✅ Passed The changes are limited to GC census reporting, nursery-cap denomination, diagnostics, tests, and the related changelog entry.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7929-object-denominated-nursery-band

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 marked this pull request as ready for review August 12, 2026 12:34

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/gc/copying.rs`:
- Around line 1859-1873: Guard the note_surviving_object_census call in the
surrounding collection flow with !untraced. Do not update the tenuring census
when PromotionLiveness::AssumeAllLive supplies whole promoted-block counts;
preserve the existing measured mean until a traced copying minor runs.

In `@crates/perry-runtime/src/gc/tenuring.rs`:
- Around line 223-283: Make both calculations overflow-safe: update the
constant-band scaling logic to avoid multiplying saturated values directly,
using quotient/remainder arithmetic or an equivalent bounded configuration path;
update nursery_cap_object_scale_permille to return 1000 before multiplying
whenever mean_surviving_object_bytes is at least
NURSERY_CAP_REFERENCE_OBJECT_BYTES. Add boundary tests covering saturated
nursery capacity and mean sizes at and above NURSERY_CAP_REFERENCE_OBJECT_BYTES.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 15bb3754-9a16-4033-80e9-181e2f65524b

📥 Commits

Reviewing files that changed from the base of the PR and between d78efca and 2b05723.

📒 Files selected for processing (4)
  • changelog.d/7961-gc-object-denominated-nursery-band.md
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/tenuring.rs
  • crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs

Comment on lines +1859 to +1873
// #7929: the object denomination of the nursery constant band, fed BEFORE
// the tenuring loop so every number `retune_after_scavenge` derives from
// the effective cap (desired survivor occupancy, the cap-scale band) reads
// one consistent factor. Both tenuring ratios are representation-invariant
// by cancellation, so this only re-denominates the constant band itself.
super::tenuring::note_surviving_object_census(
collector
.stats
.copied_bytes
.saturating_add(collector.stats.promoted_bytes),
collector
.stats
.copied_objects
.saturating_add(collector.stats.promoted_objects),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not record an untraced promotion as a moved-object census.

This call runs when untraced is true. That path uses PromotionLiveness::AssumeAllLive and assigns whole promoted-block counts to promoted_objects and promoted_bytes. It does not measure surviving moved objects. The call overwrites the previous measured mean with dead-object-inclusive data and can distort the next nursery cap.

Gate this census update on !untraced so the existing mean carries forward until a traced copying minor measures it.

Proposed fix
-    super::tenuring::note_surviving_object_census(
-        collector
-            .stats
-            .copied_bytes
-            .saturating_add(collector.stats.promoted_bytes),
-        collector
-            .stats
-            .copied_objects
-            .saturating_add(collector.stats.promoted_objects),
-    );
+    if !untraced {
+        super::tenuring::note_surviving_object_census(
+            collector
+                .stats
+                .copied_bytes
+                .saturating_add(collector.stats.promoted_bytes),
+            collector
+                .stats
+                .copied_objects
+                .saturating_add(collector.stats.promoted_objects),
+        );
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #7929: the object denomination of the nursery constant band, fed BEFORE
// the tenuring loop so every number `retune_after_scavenge` derives from
// the effective cap (desired survivor occupancy, the cap-scale band) reads
// one consistent factor. Both tenuring ratios are representation-invariant
// by cancellation, so this only re-denominates the constant band itself.
super::tenuring::note_surviving_object_census(
collector
.stats
.copied_bytes
.saturating_add(collector.stats.promoted_bytes),
collector
.stats
.copied_objects
.saturating_add(collector.stats.promoted_objects),
);
// #7929: the object denomination of the nursery constant band, fed BEFORE
// the tenuring loop so every number `retune_after_scavenge` derives from
// the effective cap (desired survivor occupancy, the cap-scale band) reads
// one consistent factor. Both tenuring ratios are representation-invariant
// by cancellation, so this only re-denominates the constant band itself.
if !untraced {
super::tenuring::note_surviving_object_census(
collector
.stats
.copied_bytes
.saturating_add(collector.stats.promoted_bytes),
collector
.stats
.copied_objects
.saturating_add(collector.stats.promoted_objects),
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/copying.rs` around lines 1859 - 1873, Guard the
note_surviving_object_census call in the surrounding collection flow with
!untraced. Do not update the tenuring census when
PromotionLiveness::AssumeAllLive supplies whole promoted-block counts; preserve
the existing measured mean until a traced copying minor runs.

Comment on lines +223 to +283
let constant_band =
gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize);
constant_band.saturating_mul(nursery_cap_object_scale_permille(
mean_surviving_object_bytes(),
)) / 1000
}

/// #7929: the mean size of the objects the last copying minor actually moved,
/// in bytes — `(copied + promoted) bytes / (copied + promoted) objects`.
///
/// Seeded at [`NURSERY_CAP_REFERENCE_OBJECT_BYTES`] so a process that has never
/// completed a copying minor paces bit-identically to the pre-#7929 collector.
pub(super) fn mean_surviving_object_bytes() -> usize {
MEAN_SURVIVING_OBJECT_BYTES.with(Cell::get)
}

/// Mean surviving object size the 16 MB constant band was calibrated against.
///
/// #7056 (the cap) and #7377/#7592 (its scale ladder) were measured on a heap
/// whose two-field object literal was **72 bytes**; #7928 right-sized that to
/// 56. The constant is the *calibration anchor*, not a claim about any current
/// representation — moving it re-tunes the cap for every program at once.
pub(super) const NURSERY_CAP_REFERENCE_OBJECT_BYTES: usize = 72;
/// Floor for the object-denomination factor (per mille). At the corpus's
/// smallest measured mean (32.8 B on `tree_wide`) the unclamped factor is 456;
/// the floor bounds how far a shrinking representation may pull the cap down,
/// so the collection *count* cannot run away on a workload whose survivors are
/// atypically small.
const NURSERY_CAP_OBJECT_SCALE_MIN_PERMILLE: usize = 500;

/// #7929: how much of the byte-denominated constant band this representation
/// should get, in per mille, so the band buys a **constant number of objects**.
///
/// The collector's trigger is denominated in bytes and its per-cycle cost is
/// per object, so a fixed byte band silently buys more collector work as
/// objects shrink: #7928 took a two-field object 72 B → 56 B and every minor
/// then moved 1.286× (= 72/56) as many objects for the same bytes, costing
/// `deeplist` +10.5% and `retain1` +8.8% wall. Scaling the band by
/// `mean / reference` restores `band / mean` — the object budget — to what the
/// band was calibrated to buy.
///
/// **Deliberately one-sided.** The factor is clamped at 1000, so a
/// representation *larger* than the reference gets the unchanged band rather
/// than a proportionally larger one. Two reasons, both measured:
///
/// * The byte-weighted mean is not a representative object size on a workload
/// whose survivors are dominated by large allocations — `push_num`'s mean is
/// **3600 B** because it survives arrays, not objects (the documented blocker
/// on #7929). One-sided clamping turns that from a ×50 cap explosion into
/// exactly today's behaviour.
/// * The corpus response to the cap is threshold-dominated (which cycle *kinds*
/// fire), so *raising* a cap is the risky direction: it is how a program
/// crosses `GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES` or lands in a #7909 budgeted
/// stall. Every program at or above the reference — `retain_wide`,
/// `retain_wide1`, `push_num`, `shapes` — is left bit-identical.
pub(super) fn nursery_cap_object_scale_permille(mean_surviving_object_bytes: usize) -> usize {
if mean_surviving_object_bytes == 0 {
return 1000;
}
(mean_surviving_object_bytes * 1000 / NURSERY_CAP_REFERENCE_OBJECT_BYTES)
.clamp(NURSERY_CAP_OBJECT_SCALE_MIN_PERMILLE, 1000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Identify repository-declared 32-bit targets or CI jobs where this arithmetic
# can overflow at practical configured nursery sizes.
rg -n --hidden \
  -g '!target/**' \
  -g 'Cargo.toml' \
  -g '*.yml' \
  -g '*.yaml' \
  -g '*.toml' \
  'i686|i586|armv7|32-bit|--target' . || true

Repository: PerryTS/perry

Length of output: 7175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- tenuring.rs relevant definitions and tests ---'
sed -n '140,330p' crates/perry-runtime/src/gc/tenuring.rs
sed -n '540,720p' crates/perry-runtime/src/gc/tenuring.rs

printf '%s\n' '--- nursery-cap configuration and arithmetic call sites ---'
rg -n -C 5 \
  'gc_scavenge_nursery_cap_bytes|PERRY_GC_SCAVENGE_NURSERY_MB|NURSERY_CAP_SCALE|MEAN_SURVIVING_OBJECT_BYTES|mean_surviving_object_bytes|nursery_cap_object_scale_permille' \
  crates/perry-runtime

printf '%s\n' '--- target and platform assumptions ---'
rg -n --hidden \
  -g '!target/**' \
  -g '*.rs' \
  -g 'Cargo.toml' \
  'usize::MAX|target_pointer_width|PERRY_GC_SCAVENGE_NURSERY_MB|SCAVENGE_NURSERY' \
  crates/perry-runtime

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact nursery-cap parser ---'
sed -n '200,228p' crates/perry-runtime/src/gc/policy.rs

printf '%s\n' '--- target pointer-width declarations ---'
rg -n --hidden \
  -g '!target/**' \
  -g '*.json' \
  -g '*.toml' \
  -g '*.yml' \
  -g '*.yaml' \
  'arm64_32|watchos|visionos|target_pointer_width|32-bit pointers|ILP32' \
  . | head -120

printf '%s\n' '--- tracked diff summary ---'
git diff --stat -- crates/perry-runtime/src/gc/tenuring.rs crates/perry-runtime/src/gc/policy.rs

printf '%s\n' '--- standalone arithmetic probe ---'
python3 - <<'PY'
def scale(mean, bits):
    max_usize = (1 << bits) - 1
    if mean == 0:
        return 1000
    # Rust usize multiplication in release mode wraps.
    wrapped = (mean * 1000) & max_usize
    return max(500, min(1000, wrapped // 72))

def scale_intended(mean):
    if mean == 0 or mean >= 72:
        return 1000
    return max(500, (mean * 1000) // 72)

for bits in (32, 64):
    limit = (1 << bits) - 1
    print(f'{bits}-bit:')
    for mean in (0, 8, 56, 71, 72, 104, 3_600,
                 limit // 1000, limit // 1000 + 1, limit):
        print(mean, scale(mean, bits), scale_intended(mean))
PY

Repository: PerryTS/perry

Length of output: 7669


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact nursery-cap parser ---'
sed -n '200,228p' crates/perry-runtime/src/gc/policy.rs

printf '%s\n' '--- target pointer-width declarations ---'
rg -n --hidden \
  -g '!target/**' \
  -g '*.json' \
  -g '*.toml' \
  -g '*.yml' \
  -g '*.yaml' \
  'arm64_32|watchos|visionos|target_pointer_width|32-bit pointers|ILP32' \
  . | head -120

printf '%s\n' '--- tracked diff summary ---'
git diff --stat -- crates/perry-runtime/src/gc/tenuring.rs crates/perry-runtime/src/gc/policy.rs

printf '%s\n' '--- standalone arithmetic probe ---'
python3 - <<'PY'
def scale(mean, bits):
    max_usize = (1 << bits) - 1
    if mean == 0:
        return 1000
    wrapped = (mean * 1000) & max_usize
    return max(500, min(1000, wrapped // 72))

def scale_intended(mean):
    if mean == 0 or mean >= 72:
        return 1000
    return max(500, (mean * 1000) // 72)

for bits in (32, 64):
    limit = (1 << bits) - 1
    print(f'{bits}-bit:')
    for mean in (0, 8, 56, 71, 72, 104, 3_600,
                 limit // 1000, limit // 1000 + 1, limit):
        print(mean, scale(mean, bits), scale_intended(mean))
PY

Repository: PerryTS/perry

Length of output: 7669


Make both permille calculations overflow-safe.

PERRY_GC_SCAVENGE_NURSERY_MB accepts any positive usize. On 64-bit targets, a saturated constant_band can overflow the current u64 multiplication, causing a debug panic or an incorrect release cap. Use quotient/remainder arithmetic or bound the configuration.

nursery_cap_object_scale_permille also multiplies mean_surviving_object_bytes before checking its upper range. Check mean_surviving_object_bytes >= NURSERY_CAP_REFERENCE_OBJECT_BYTES first. Add boundary tests for both cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/tenuring.rs` around lines 223 - 283, Make both
calculations overflow-safe: update the constant-band scaling logic to avoid
multiplying saturated values directly, using quotient/remainder arithmetic or an
equivalent bounded configuration path; update nursery_cap_object_scale_permille
to return 1000 before multiplying whenever mean_surviving_object_bytes is at
least NURSERY_CAP_REFERENCE_OBJECT_BYTES. Add boundary tests covering saturated
nursery capacity and mean sizes at and above NURSERY_CAP_REFERENCE_OBJECT_BYTES.

@proggeramlug
proggeramlug merged commit a87bf3e into main Aug 12, 2026
1 of 19 checks passed
@proggeramlug
proggeramlug deleted the gc/7929-object-denominated-nursery-band branch August 12, 2026 12:50
@proggeramlug

Copy link
Copy Markdown
Contributor Author

⚠️ Post-merge correction: on a healthy collector the shipped term is net-NEGATIVE, and #7965 is a hard prerequisite

#7961 merged (109de930f) while its validation was still running. The validation
finished and contradicts the A/B in the PR body. Recording it here rather
than leaving the PR's numbers standing.

What the PR measured, and why it was misleading

The PR's A/B was taken against ac52a5c38, which contains #7965 (bisected in
that issue to #7944 / #7902 — untraced promotion stopped crediting the
old-reclaim baseline, so deeplist/retain* each gained a futile full
mark-sweep). Against that regressed base the term reads as a large win, because
it happens to keep old-gen under the 48 MB one-shot threshold.

Re-run against the pre-regression base (e886b56dd, the commit before
#7902), with the same two commits cherry-picked on top so the only variable is
the term itself:

program base fulls / instr +term fulls / instr Δobjects Δinstr ΔRSS
deeplist 0 / 1107.2 M 0 / 1093.5 M −11.8% −1.2% −0.0%
retain1 0 / 1293.7 M 1 / 3342.4 M −11.8% +158.4% +25.4%
retain 0 / 2596.4 M 1 / 4635.8 M −5.1% +78.5% +2.6%
retain_wide 0 / 3029.3 M 0 / 3027.7 M 0 −0.1% 0.0%

The term itself introduces the futile full on retain1 and retain.

Mechanism — the same defect as #7965, reached from the other side

old_reclaim_pressure_due's absolute arm is a one-shot latch:

(old_in_use >= T && baseline < T) || ...     // T = 48 MB

baseline (GC_LAST_OLD_RECLAIM_IN_USE_BYTES) is a running sum of promotion
deltas
, so whether it ends up above or below T is decided by promotion step
size
, not by the heap. On retain1:

promotion steps final baseline latch
base 18.74 + 34.60 MB 53.34 MB > T disarmed by overshoot
+term 18.74 + 26.21 MB 44.95 MB < T still armed

The smaller band makes the second step smaller, the baseline lands 3 MB short of
T, and the latch survives. retain1's all array — a large object born straight
into old-gen, whose growth never passes through promotion and so never credits
the baseline — then pushes old_in_use past 48 MB and the latch fires. The full
reports freed_bytes = 8 257 632, i.e. the array's dead backing stores: a
whole-heap collection to reclaim 8 MB.

deeplist has no such old-born array, which is why it is the one program in the
table that stays clean — and its −1.2% is therefore the honest value of the
object denomination on a healthy collector, not the −72.3% the PR quotes.

So #7965 and this are one defect: a valve whose firing is decided by
granularity rather than by the quantity it claims to measure.
#7902 zeroed the
baseline; this term lowers it. Either way the latch is left armed and a
100 %-live old-gen gets a futile full.

What I recommend

  1. Do not revert fix(gc): denominate the nursery constant band in objects (#7929) #7961 yet. On current main both arms already carry the
    perf(gc): main regressed the retain cluster 2.2-4.8x — retain now runs 2 full collections where it ran none (suspect #7901/#7902) #7965 full, so the term is measured there as retain1 −10.9%, retain
    −63.1%, deeplist −72.3% and is a net win today. Reverting now would make
    main worse.
  2. Fixing perf(gc): main regressed the retain cluster 2.2-4.8x — retain now runs 2 full collections where it ran none (suspect #7901/#7902) #7965 must re-measure this term in the same change. The moment the
    baseline is credited correctly, retain1/retain flip from −11 %/−63 % to
    +158 %/+78 %. That is a landmine, and it is armed.
  3. The durable fix is to stop the latch depending on step size at all — either
    credit old-gen growth to the baseline regardless of how it arrived, or refuse
    to schedule a reclaim when the last cycle measured young_survival_permille = 1000 (it was 1000 on every cycle in this table, i.e. the collector had just
    finished proving the reclaim cannot free anything).

What stands from the PR

  • The corpus census, and that only the constant band is mis-denominated (both
    tenuring.rs ratios and the tenured-proportional arm are invariant by
    cancellation).
  • The one-sided clamp and its control set — push_num's 3600 B array-dominated
    mean is neutralised, and the 5 factor-1.000 programs calibrate the A/B's
    link-layout noise floor at −0.3 %…−3.5 %.
  • The recorded blocker is still refuted: an extra copying minor costs ~0.5 M
    instructions (churn: 43→172 minors for +4 % instructions and −60 % RSS), so
    "more cycles pay the fixed root scan" was never the reason not to do this.
  • What is not established, and what the PR over-claimed: that the term is worth
    ~10 % on the taxed programs. On a healthy collector it is worth ~1 %
    (deeplist, clean cell, 3 minors and 0 fulls in both arms). The ~10 % figures
    in the PR body and in REPR-NOTES.md §3d are entangled with cycle-kind
    changes and should not be quoted as per-object work.

Full working: gc-handoff/BUDGET-NOTES.md §8.

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.

perf(gc): the nursery/promotion budget is denominated in BYTES but the collector's cost is per-OBJECT — compaction is taxed back

1 participant