Skip to content

gc: throttle the interpreter safepoint on allocated bytes, not allocation count - #847

Merged
youknowone merged 1 commit into
mainfrom
ec-wiring
Jul 28, 2026
Merged

gc: throttle the interpreter safepoint on allocated bytes, not allocation count#847
youknowone merged 1 commit into
mainfrom
ec-wiring

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Why

The interpreter GC safepoint (PYRE_GC_INTERP) ran a whole-heap old-gen major every COLLECT_THRESHOLD — 65536 — interpreter-routed allocations.

Nothing upstream counts allocations. The allocator accumulates a byte total (nursery_free = result + totalsize, minimark.py:556-557), and a major is scheduled against get_total_memory_used() (incminimark.py:1264-1268) reaching a threshold that set_major_threshold_from (incminimark.py:575-594) re-derives from what survived the last one, capped by a growth rate and floored at min_heap_size (incminimark.py:307).

A count cannot bound a heap, because one allocation is not one size — 65536 boxed characters and 65536 frames are three orders of magnitude apart. The count-based threshold is a pyre-only mechanism with no upstream counterpart.

What

note_alloc takes the payload size that was allocated. The safepoint compares accumulated bytes against a threshold set to the surviving old-gen total times major_collection_threshold - 1 (incminimark.py:198's 1.82 default), floored at 8 MiB.

The comparison is kept in that delta form deliberately: having allocated b bytes since the last major, the total is live + b, so incminimark's live + b >= live * threshold is exactly b >= live * (threshold - 1). The per-dispatch test therefore stays a single atomic compare, exactly as the counter was, and the heap-stats read happens only after a collection rather than on every bytecode dispatch.

Adds try_gc_heap_stats alongside try_gc_jitframe_empty, routed to majit_gc::active_heap_stats — whose existing doc already names the interpreter safepoint as its intended consumer.

The eight call sites pass their real allocation size. w_str_from_wtf8_managed charges both the header and the value box holding the WTF-8 bytes, since strings are exactly where a count diverges most from bytes.

Measurements

All min-of-three. No workload regressed.

workload before after
s.lower() loop, default 0.92s / 232 MB 0.79s / 232 MB
s.lower() loop, PYRE_GC_INTERP=1 0.93s / 151 MB 1.00s / 146 MB
RSS curve, PYRE_GC_INTERP=1 148→161, flat 165→170, flat

RSS stays bounded — the point is amortising the major, not collecting less.

With the JIT off, so the interpreter allocation path this module targets is actually taken, a string-boxing loop routed through the managed allocator ran 2.55s against 2.72s for the immortal allocator. On the same workload with the JIT on, all four combinations are indistinguishable (0.53–0.54s), because the trace does not reach the interpreter allocation path at all.

No claim is made here that this unblocks routing more sites through the managed constructors. Measuring that needs a managed-allocator build on this same base under the count policy, which was not run. An earlier experiment on an older base did show the count policy imposing a 2.8x penalty on such routing (2.71s → 7.48s), and that penalty is absent under the byte policy — but the control arm for the current base is missing, so this PR is offered on the orthodoxy argument, not on that one.

Verification

  • check.py 326/326 on dynasm and cranelift, each at default and PYRE_FBW_MULTIFRAME=1, run serially
  • cargo test -p pyre-jit-trace -p pyre-jit --features dynasm: 641 passed, 0 failed
  • getframe/traceback discriminators 9/9 on both backends
  • str/bytes identity probes unchanged on both backends (30/30 against the pypy3 oracle), confirming no interaction with str/bytes: receiver identity for whole-span cuts #825
  • cargo fmt --all --check clean

Verification ran against the tree before the last rebase onto #839; the diff is unchanged by it, but the suite has not been re-run on this exact base.

authored by Claude

…tion count

The safepoint ran a whole-heap old-gen major every `COLLECT_THRESHOLD` (65536)
interpreter-routed allocations.  Nothing upstream counts allocations: the
allocator accumulates a byte total (`nursery_free = result + totalsize`,
minimark.py:556-557), and a major is scheduled against
`get_total_memory_used()` (incminimark.py:1264-1268) reaching a threshold that
`set_major_threshold_from` (incminimark.py:575-594) re-derives from what
survived the last one.  A count cannot bound a heap, because one allocation is
not one size -- 65536 boxed characters and 65536 frames are three orders of
magnitude apart.

`note_alloc` now takes the payload size that was allocated, and the safepoint
compares accumulated bytes against a threshold set to the surviving old-gen
total times `major_collection_threshold - 1` (incminimark.py:198's 1.82
default), floored at a `min_heap_size` (incminimark.py:307) of 8 MiB.

The comparison is kept in that delta form on purpose: having allocated `b`
bytes since the last major, the total is `live + b`, so incminimark's
`live + b >= live * threshold` is exactly `b >= live * (threshold - 1)`.  The
per-dispatch test therefore stays a single atomic compare, as the counter was,
and the heap-stats read happens only after a collection.

Adds `try_gc_heap_stats` alongside `try_gc_jitframe_empty`, routed to
`majit_gc::active_heap_stats`, whose doc already names the interpreter
safepoint as its consumer.

Measurements, all min-of-three: no workload regressed.  `s.lower()` in a loop
is unchanged (0.93s -> 1.00s under `PYRE_GC_INTERP=1`, 0.92s -> 0.79s without
it) and its RSS stays bounded and flat.  With the JIT off, so the interpreter
allocation path this module targets is actually taken, a string-boxing loop
routed through the managed allocator ran 2.55s against 2.72s for the immortal
allocator.

No claim is made here that this unblocks routing more sites through the
managed constructors: measuring that needs a managed-allocator build on this
same base for the count policy, which was not run.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 056241d6-152b-4119-8199-381d5843afe5

📥 Commits

Reviewing files that changed from the base of the PR and between ca16a8f and 15f4215.

📒 Files selected for processing (11)
  • pyre/pyre-interpreter/src/pyframe.rs
  • pyre/pyre-interpreter/src/pytraceback.rs
  • pyre/pyre-jit/src/eval.rs
  • pyre/pyre-object/src/floatobject.rs
  • pyre/pyre-object/src/gc_hook.rs
  • pyre/pyre-object/src/gc_interp.rs
  • pyre/pyre-object/src/generator.rs
  • pyre/pyre-object/src/interp_exceptions.rs
  • pyre/pyre-object/src/intobject.rs
  • pyre/pyre-object/src/longobject.rs
  • pyre/pyre-object/src/unicodeobject.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ec-wiring

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.

@github-actions

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 15f4215).
Updated: 2026-07-28T01:28:30.655Z

Files in the reviewed diff
pyre/pyre-interpreter/src/pyframe.rs
pyre/pyre-interpreter/src/pytraceback.rs
pyre/pyre-jit/src/eval.rs
pyre/pyre-object/src/floatobject.rs
pyre/pyre-object/src/gc_hook.rs
pyre/pyre-object/src/gc_interp.rs
pyre/pyre-object/src/generator.rs
pyre/pyre-object/src/interp_exceptions.rs
pyre/pyre-object/src/intobject.rs
pyre/pyre-object/src/longobject.rs
pyre/pyre-object/src/unicodeobject.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • pyre/pyre-object/src/gc_interp.rs:219-222 ↔ rpython/memory/gc/incminimark.py:1288-1290: pyre tests a private allocation delta, "ALLOC_BYTES_SINCE_GC >= NEXT_MAJOR_BYTES", rather than whether the GC’s current total heap usage has crossed its absolute threshold. The asserted equivalence (live + b) is false because b observes only selected interpreter allocations; GC-managed allocations outside those call sites are absent. Consequently, a heap can exceed its PyPy major threshold without this safepoint collecting.

  • pyre/pyre-object/src/gc_interp.rs:220-221 ↔ rpython/memory/gc/incminimark.py:575-594,2570-2577: the new threshold formula only implements live * 0.82 as a delta. It omits PyPy’s growth_rate_max cap, max_delta cap, configurable min_heap_size/max_heap_size, and subtraction of kept_alive_by_finalizer. Thus NEXT_MAJOR_BYTES can grow beyond the next threshold PyPy would permit.

  • pyre/pyre-object/src/floatobject.rs:64, intobject.rs:114, longobject.rs:156, generator.rs:163, interp_exceptions.rs:600, pyre-interpreter/src/pyframe.rs:598, and pytraceback.rs:139 ↔ rpython/memory/gc/incminimark.py:1264-1268,1288-1290: each new byte charge passes only the payload size, while the GC’s reported old-generation total includes the allocation’s GcHeader (majit/majit-gc/src/collector.rs:3742-3744). The delta budget is therefore systematically smaller than the heap quantity it purports to model.

  • pyre/pyre-object/src/unicodeobject.rs:237-239 ↔ rpython/memory/gc/incminimark.py:1102-1110,1264-1268: "W_UNICODE_OBJECT_SIZE + byte_len" does not describe the allocations made here. The GC-managed value holder is only size_of::<Wtf8Buf>() (gc_storage.rs:30-37), while its UTF-8 buffer is Rust-managed external storage; moreover byte_len is not necessarily its allocated capacity. PyPy accounts external/raw allocations through memory-pressure handling, not by treating a string’s logical length as GC heap bytes.

3. Pre-existing mismatches (already present before this patch)

  • pyre/pyre-object/src/tupleobject.rs:189 ↔ rpython/memory/gc/incminimark.py:1288-1290: stable GC allocations such as tuple headers do not call gc_interp::note_alloc, so the interpreter safepoint has never represented the whole managed heap. This predates the patch; the new delta-based threshold relies on that incomplete domain.

  • pyre/pyre-object/src/gc_interp.rs:205-223 ↔ rpython/memory/gc/incminimark.py:667,810: pyre runs a whole old-generation collection only from bytecode-dispatch safepoints, whereas MiniMark drives collection from allocation/minor-collection progress. The pre-existing scheme also resets a shared atomic counter after collection, which can discard concurrent allocation charges.

4. Structural adaptations

  • pyre/pyre-object/src/gc_interp.rs:181-195 ↔ rpython/memory/gctransform/framework.py:803-856: using a non-moving old-generation collection and restricting it to selected eval-loop nesting levels is a Rust/free-threading adaptation. RPython’s GC transform roots all live translated references around allocation calls; pyre cannot safely perform a moving minor collection while arbitrary PyObjectRefs remain only on the Rust stack.

  • pyre/pyre-object/src/gc_hook.rs:423-453 and pyre/pyre-jit/src/eval.rs:144-149 ↔ rpython/memory/gc/incminimark.py:1264-1268: the runtime function-pointer heap-stat bridge is a necessary Rust/JIT boundary adaptation. RPython accesses collector state directly from translated GC code.

  • pyre/pyre-interpreter/src/pyframe.rs:582-605 and pyre/pyre-interpreter/src/pytraceback.rs:128-145 ↔ pypy/interpreter/pyframe.py:52-63, pypy/interpreter/pytraceback.py:17-32: stable, header-bearing Rust allocations preserve raw-pointer address stability where PyPy simply uses normal W_Root GC objects. This is an implementation-language adaptation, not a Python-visible semantic divergence.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 15f4215956

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// proportionally more between majors and a steady-state one stops
// collecting altogether.
let (oldgen_live, _nursery_used) = crate::gc_hook::try_gc_heap_stats();
let next = (oldgen_live / 100).saturating_mul(MAJOR_GROWTH_DELTA_PCT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply the collector's capped next-major threshold

When PYRE_GC_INTERP runs with a large surviving heap—particularly with PYPY_GC_MAX_DELTA, PYPY_GC_MAX, or PYPY_GC_MAJOR_COLLECT configured—this hard-coded 0.82 * oldgen_live delta is not the threshold established by the collector. finish_incremental_cycle in majit/majit-gc/src/collector.rs:2658-2663 caps growth by max_delta, and set_major_threshold_from applies the configured growth, minimum, and maximum limits; bypassing those values can let cold-interpreter stable allocations grow far beyond the requested cap before another old-gen collection occurs. Expose the collector's actual remaining threshold instead of reconstructing it here.

AGENTS.md reference: AGENTS.md:L195-L195

Useful? React with 👍 / 👎.

Comment on lines +173 to +174
pub fn note_alloc(size: usize) {
ALLOC_BYTES_SINCE_GC.fetch_add(size, Ordering::Relaxed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Charge the allocator's total object size

For PYRE_GC_INTERP workloads dominated by small stable allocations, the call sites pass only the payload size, while alloc_oldgen_typed in majit/majit-gc/src/collector.rs:3742-3744 adds GcHeader::SIZE and OldGen rounds/min-sizes the allocation before including it in oldgen_live. The delta counter and its threshold therefore measure different byte quantities, systematically delaying collection and allowing the actual high-water mark to exceed the intended budget; Unicode similarly omits the fixed Wtf8Buf storage-box allocation while charging its external byte length. Account the same total, rounded allocation size used by old-gen statistics.

AGENTS.md reference: AGENTS.md:L195-L195

Useful? React with 👍 / 👎.

@youknowone
youknowone merged commit 7476021 into main Jul 28, 2026
19 checks passed
@youknowone
youknowone deleted the ec-wiring branch July 28, 2026 05:00
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