Skip to content

memory(gc): untraced promotion can retain an old-heap-sized phase-change cohort as live #7902

Description

@proggeramlug

Summary

The untraced whole-block promotion path charges retained garbage from the previous cycle's survival ratio, not from the liveness of the bytes it is currently promoting. After a live-to-dead phase change, a prior 1000‰ measurement makes implied_dead = 0, so the advertised 32 MiB PROMOTED_DEAD_BUDGET_BYTES is not charged while the collector blindly promotes the new dead cohort.

The separate stale-estimate budget allows max(128 MiB, old-gen-at-last-measurement) of such promotion before forcing a traced cycle. On a large live heap that can be hundreds of MiB or GiB. The eventual traced cycle measures only its current young cohort; it cannot recover or retroactively charge garbage already promoted by the preceding untraced cycles. Those bytes remain in old-gen until a full collection.

This is a bounded-policy design, not an immediate memory-safety bug, but the bound is much larger than the comments/changelog's 32 MiB footprint claim and grows with old-gen size.

Phase-change sequence

  1. A traced copying minor observes 1000‰ young survival.
  2. The application changes phase and begins allocating/droping short-lived cohorts.
  3. The next promotion(s) satisfy the stale 1000‰ predictor and skip tracing.
  4. Every parseable young object is promoted and accounted as live, including objects that are now unreachable.
  5. note_untraced_promotion() computes zero implied dead bytes from the stale 1000‰ value, so PROMOTED_DEAD_BYTES remains unchanged.
  6. Only after UNTRACED_PROMOTED_BYTES reaches max(128 MiB, old-gen-at-last-measurement) does a traced promoting cycle run. It updates the future predictor but cannot identify/reclaim the earlier promoted garbage.
  7. credit_promoted_bytes_to_old_baseline() and live-census publication treat all untraced promoted bytes as live, which can defer the full collection needed to reclaim them.

Evidence

Impact

  • A phase-changing server can park up to the old generation's prior live size again as dead-but-accounted-live memory.
  • Major pacing and old-reclaim pacing are biased toward retaining it because the promoted delta is credited as clean/live.
  • On device-constrained heaps, the fixed 128 MiB floor alone is larger than some intended heap budgets.
  • Telemetry reports the assumed-live promoted bytes as heapUsed until a full census/reclaim, so the policy cannot distinguish confidence from measured liveness.

Acceptance criteria

  • State and enforce a worst-case retained-garbage bound for untraced promotion under abrupt phase changes, not only stationary ratios.
  • Scale the untraced allowance to the device heap budget; do not retain a 128 MiB minimum on a smaller configured heap.
  • Do not credit AssumeAllLive bytes to the clean old-reclaim baseline as equivalent to marked-live promotions, or track them in a separate uncertain/debt class.
  • When the forced measuring cycle contradicts the predictor, schedule/recover the previously assumed-live cohort promptly rather than merely changing the next-cycle decision.
  • Add an end-to-end 1000‰ live phase -> dead churn phase ratchet that asserts old-gen growth, full-GC timing, RSS, and heapUsed remain within the documented bound.
  • Add policy tests at a large old-gen baseline and at a small PERRY_GC_HEAP_LIMIT.

Audit context

Found while re-auditing the v0.5.1473→v0.5.1481 GC delta, specifically #7888 and #7895. No heavy benchmark was run for this audit; the finding follows from the policy/state transitions and existing source tests.

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions