Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyre/pyre-interpreter/src/pyframe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ impl FrameBox {
debug_assert!(pyre_object::gc_hook::try_gc_owns_object(
frame.locals_cells_stack_w as *mut u8
));
pyre_object::gc_interp::note_alloc();
pyre_object::gc_interp::note_alloc(std::mem::size_of::<PyFrame>());
let ptr = raw as *mut PyFrame;
unsafe {
std::ptr::write(ptr, frame);
Expand Down
2 changes: 1 addition & 1 deletion pyre/pyre-interpreter/src/pytraceback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ pub fn w_pytraceback_new(
PYTRACEBACK_OBJECT_SIZE,
);
if !raw.is_null() {
pyre_object::gc_interp::note_alloc();
pyre_object::gc_interp::note_alloc(PYTRACEBACK_OBJECT_SIZE);
let ptr = raw as *mut PyTraceback;
unsafe {
std::ptr::write(ptr, value);
Expand Down
8 changes: 8 additions & 0 deletions pyre/pyre-jit/src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ fn pyre_object_gc_collect_oldgen_trampoline() {
majit_gc::collect_oldgen_nonmoving();
}

/// Trampoline for the interpreter safepoint's `(oldgen_total, nursery_used)`
/// read, used to re-derive its next-major threshold from what survived a
/// collection (`set_major_threshold_from`, incminimark.py:575-594).
fn pyre_object_gc_heap_stats_trampoline() -> (usize, usize) {
majit_gc::active_heap_stats()
}

fn pyre_object_gc_set_enabled_trampoline(enabled: bool) {
majit_gc::gc_set_enabled(enabled);
}
Expand Down Expand Up @@ -3463,6 +3470,7 @@ fn install_pyre_object_hooks() {
);
pyre_object::register_gc_collect_hook(pyre_object_gc_collect_trampoline);
pyre_object::gc_hook::register_gc_collect_oldgen_hook(pyre_object_gc_collect_oldgen_trampoline);
pyre_object::gc_hook::register_gc_heap_stats_hook(pyre_object_gc_heap_stats_trampoline);
pyre_object::gc_hook::register_gc_set_enabled_hook(pyre_object_gc_set_enabled_trampoline);
pyre_object::gc_hook::register_gc_finalizer_hooks(
pyre_object_gc_register_finalizer_trampoline,
Expand Down
2 changes: 1 addition & 1 deletion pyre/pyre-object/src/floatobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub fn w_float_new(value: f64) -> PyObjectRef {
if crate::gc_interp::enabled() {
let raw = crate::gc_hook::try_gc_alloc_stable_raw(W_FLOAT_GC_TYPE_ID, W_FLOAT_OBJECT_SIZE);
if !raw.is_null() {
crate::gc_interp::note_alloc();
crate::gc_interp::note_alloc(W_FLOAT_OBJECT_SIZE);
unsafe {
std::ptr::write(raw as *mut W_FloatObject, obj);
return raw as PyObjectRef;
Expand Down
33 changes: 33 additions & 0 deletions pyre/pyre-object/src/gc_hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,39 @@ pub fn try_gc_jitframe_empty() -> bool {
}
}

/// Signature of the host-side `(oldgen_total, nursery_used)` byte-stats
/// callback. `oldgen_total` is `get_total_memory_used`
/// (incminimark.py:1264-1268).
pub type GcHeapStatsHookFn = fn() -> (usize, usize);

majit_gc::global_hook!(static GC_HEAP_STATS_HOOK: GcHeapStatsHookFn);

/// Install the heap-byte-stats callback.
pub fn register_gc_heap_stats_hook(hook: GcHeapStatsHookFn) {
GC_HEAP_STATS_HOOK.set(Some(hook));
}

/// Remove the heap-byte-stats callback.
pub fn clear_gc_heap_stats_hook() {
GC_HEAP_STATS_HOOK.set(None);
}

/// Report `(oldgen_total, nursery_used)` in bytes via the installed hook.
/// `(0, 0)` when none is installed, which leaves the interpreter safepoint on
/// its `min_heap_size` floor.
///
/// Reads the runtime-mutable `GC_HEAP_STATS_HOOK` fn-pointer cell, not a
/// build-time constant, so the JIT residualizes the call instead of tracing
/// into it (`@dont_look_inside`, the [`try_gc_jitframe_empty`] twin). It
/// cannot raise.
#[majit_macros::dont_look_inside]
pub fn try_gc_heap_stats() -> (usize, usize) {
match GC_HEAP_STATS_HOOK.get() {
Some(f) => f(),
None => (0, 0),
}
}

/// Signature of the host-side root-register callbacks.
/// `slot` is a pointer to a slot holding a `PyObjectRef`
/// (equivalently `*mut u8`); the GC treats it as a live root until
Expand Down
70 changes: 55 additions & 15 deletions pyre/pyre-object/src/gc_interp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
//! path dict/set/list/instances already use), so they become GC-tracked without
//! the move hazard, and trigger a full mark-sweep at a bytecode-dispatch
//! safepoint (loop top, where the only live refs are in the frame and reachable
//! through the registered `pyframe` root walker). The collection is throttled by
//! an allocation counter so the old-gen high-water stays bounded.
//! through the registered `pyframe` root walker). The collection is throttled
//! the way `set_major_threshold_from` (incminimark.py:575-594) throttles a
//! major: on bytes allocated since the last one, against a threshold re-derived
//! from what survived it, so the cost is amortised against heap growth.
//!
//! Gated off by default on native; enabled with `PYRE_GC_INTERP=1`. On wasm it
//! is on by default — the env read returns nothing there, and the interp-path
Expand Down Expand Up @@ -101,12 +103,37 @@ pub fn at_outermost_activation() -> bool {
/// routing+collection while diagnosing root-completeness.
static COLLECT_STATE: AtomicU8 = AtomicU8::new(0);

/// Number of interpreter-routed object allocations since the last collection.
static ALLOC_SINCE_GC: AtomicUsize = AtomicUsize::new(0);
/// Bytes of interpreter-routed object allocation since the last collection.
///
/// The allocator accumulating a byte total is `nursery_free = result +
/// totalsize` (minimark.py:556-557); a count of allocations has no upstream
/// counterpart, and cannot bound anything because one allocation is not one
/// size.
static ALLOC_BYTES_SINCE_GC: AtomicUsize = AtomicUsize::new(0);

/// Bytes that must be allocated before the next safepoint collection.
///
/// `set_major_threshold_from` (incminimark.py:575-594) schedules the next major
/// at `get_total_memory_used() * major_collection_threshold`, so the major cost
/// is amortised against how much the heap grew rather than paid on a fixed
/// cadence. `threshold_reached` (incminimark.py:1288-1290) is the comparison.
///
/// Held here as the equivalent *delta*: having allocated `b` bytes since the
/// last major, the total is `live + b`, so `live + b >= live * threshold` is
/// just `b >= live * (threshold - 1)`. That keeps the safepoint's per-dispatch
/// test a plain atomic compare and confines the heap-stats read to the far
/// rarer post-collection update.
static NEXT_MAJOR_BYTES: AtomicUsize = AtomicUsize::new(MIN_HEAP_BYTES);

/// Allocations between safepoint collections. At ~24-40 B per int/float this
/// bounds the dead-object high-water to a couple of MB.
const COLLECT_THRESHOLD: usize = 1 << 16;
/// `major_collection_threshold - 1` in hundredths (incminimark.py:198's 1.82
/// default, as the growth delta 0.82). Integer maths keeps the safepoint free
/// of floating point.
const MAJOR_GROWTH_DELTA_PCT: usize = 82;

/// Floor for [`NEXT_MAJOR_BYTES`], mirroring `min_heap_size`
/// (incminimark.py:307). Without it a small live heap would schedule the next
/// major almost immediately and the collection cost would dominate again.
const MIN_HEAP_BYTES: usize = 8 << 20;

/// Whether `PYRE_GC_INTERP` routes int/float allocations through the GC and
/// arms the dispatch-loop safepoint. Reads the env once, then caches.
Expand All @@ -129,16 +156,22 @@ pub fn enabled() -> bool {
}
}

/// Account for one interpreter-routed allocation. Called from `w_int_new` /
/// `w_float_new` after a successful `try_gc_alloc_stable`.
/// Account for `size` bytes of interpreter-routed allocation. Called after a
/// successful `try_gc_alloc_stable` with the payload size that succeeded.
///
/// Takes the size because the safepoint's budget is a byte budget: the
/// allocator's own accumulation is `nursery_free = result + totalsize`
/// (minimark.py:556-557). Counting allocations instead lets a workload that
/// boxes many small objects trigger a whole-heap major far more often than the
/// bytes it reclaims justify.
///
/// Touches the runtime-mutable `ALLOC_SINCE_GC` atomic; the value is not a
/// build-time constant, so the JIT residualises the call instead of tracing
/// Touches the runtime-mutable `ALLOC_BYTES_SINCE_GC` atomic; the value is not
/// a build-time constant, so the JIT residualises the call instead of tracing
/// into it (`@dont_look_inside`, the [`enabled`] sibling). A `()` return has no
/// discriminant to erase.
#[majit_macros::dont_look_inside]
pub fn note_alloc() {
ALLOC_SINCE_GC.fetch_add(1, Ordering::Relaxed);
pub fn note_alloc(size: usize) {
ALLOC_BYTES_SINCE_GC.fetch_add(size, Ordering::Relaxed);
Comment on lines +173 to +174

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 👍 / 👎.

}

/// Dispatch-loop safepoint: when enough interpreter objects have accumulated,
Expand Down Expand Up @@ -175,11 +208,18 @@ pub fn safepoint() {
}
if collect_enabled()
&& at_outermost_activation()
&& ALLOC_SINCE_GC.load(Ordering::Relaxed) >= COLLECT_THRESHOLD
&& ALLOC_BYTES_SINCE_GC.load(Ordering::Relaxed) >= NEXT_MAJOR_BYTES.load(Ordering::Relaxed)
&& crate::gc_hook::try_gc_jitframe_empty()
{
crate::gc_hook::try_gc_collect_oldgen();
ALLOC_SINCE_GC.store(0, Ordering::Relaxed);
// `set_major_threshold_from` (incminimark.py:575-594) re-derives the
// next threshold from what survived, so a heap that keeps growing pays
// 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 👍 / 👎.

NEXT_MAJOR_BYTES.store(next.max(MIN_HEAP_BYTES), Ordering::Relaxed);
ALLOC_BYTES_SINCE_GC.store(0, Ordering::Relaxed);
}
}

Expand Down
2 changes: 1 addition & 1 deletion pyre/pyre-object/src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ fn w_generator_or_coroutine_new(
let raw =
crate::gc_hook::try_gc_alloc_stable_raw(W_GENERATOR_GC_TYPE_ID, W_GENERATOR_OBJECT_SIZE);
if !raw.is_null() {
crate::gc_interp::note_alloc();
crate::gc_interp::note_alloc(W_GENERATOR_OBJECT_SIZE);
unsafe {
std::ptr::write(raw as *mut GeneratorIterator, value);
}
Expand Down
2 changes: 1 addition & 1 deletion pyre/pyre-object/src/interp_exceptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ fn w_exception_new_empty_impl(kind: ExcKind, immortal: bool) -> PyObjectRef {
unsafe {
std::ptr::write(raw as *mut W_BaseException, value);
}
crate::gc_interp::note_alloc();
crate::gc_interp::note_alloc(W_BASE_EXCEPTION_SIZE);
crate::gc_hook::try_gc_write_barrier(raw);
return raw as PyObjectRef;
}
Expand Down
2 changes: 1 addition & 1 deletion pyre/pyre-object/src/intobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub fn w_int_new(value: i64) -> PyObjectRef {
if crate::gc_interp::enabled() {
let raw = crate::gc_hook::try_gc_alloc_stable_raw(W_INT_GC_TYPE_ID, W_INT_OBJECT_SIZE);
if !raw.is_null() {
crate::gc_interp::note_alloc();
crate::gc_interp::note_alloc(W_INT_OBJECT_SIZE);
unsafe {
std::ptr::write(raw as *mut W_IntObject, obj);
return raw as PyObjectRef;
Expand Down
4 changes: 2 additions & 2 deletions pyre/pyre-object/src/longobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ pub fn w_long_from_raw(value: *mut BigInt) -> PyObjectRef {
// until the wrapper is initialized and remembered below.
let raw = crate::gc_hook::try_gc_alloc_stable_raw(W_LONG_GC_TYPE_ID, W_LONG_OBJECT_SIZE);
if !raw.is_null() {
// Advance the dispatch-loop safepoint counter, as w_int_new /
// Charge the dispatch-loop safepoint's byte budget, as w_int_new /
// w_float_new do for their stable allocs — otherwise a long-dominated
// interpreter workload never reaches the safepoint threshold and the
// dead old-gen long wrappers + their bigint payloads accumulate.
crate::gc_interp::note_alloc();
crate::gc_interp::note_alloc(W_LONG_OBJECT_SIZE);
unsafe {
std::ptr::write(
raw as *mut W_LongObject,
Expand Down
4 changes: 3 additions & 1 deletion pyre/pyre-object/src/unicodeobject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ pub fn w_str_from_wtf8_managed(value: Wtf8Buf) -> PyObjectRef {
let recovered = unsafe { (*value).clone() };
return w_str_from_wtf8_immortal(recovered);
}
crate::gc_interp::note_alloc();
// Both the header and the value box holding the WTF-8 bytes came from the
// collector, so the safepoint's byte budget accounts for both.
crate::gc_interp::note_alloc(W_UNICODE_OBJECT_SIZE + byte_len);
unsafe {
std::ptr::write(raw as *mut W_UnicodeObject, unicode);
raw as PyObjectRef
Expand Down
Loading