-
Notifications
You must be signed in to change notification settings - Fork 19
gc: separate a failed managed allocation from an absent GC #1020
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,11 +13,18 @@ | |
| //! the same pointer to every thread, and a collector running on an arbitrary | ||
| //! thread must see it even if that thread never ran the install path. | ||
| //! | ||
| //! Callers use [`try_gc_alloc`] which returns `None` when no hook is | ||
| //! installed — they fall back to the `Box::into_raw` path in | ||
| //! that case. Incremental migration drops the `Box::into_raw` | ||
| //! fallback at each call site as the hook's reliability is verified | ||
| //! under the full bench suite. | ||
| //! Callers use [`try_gc_alloc`], whose two non-pointer answers mean | ||
| //! different things: `None` is *no GC owns the heap*, `Some(null)` is *a GC | ||
| //! owns the heap and this allocation failed*. Only the first licenses the | ||
| //! `Box::into_raw` path. [`GcAllocOutcome`] names the two so a call site | ||
| //! cannot collapse them: substituting a raw object for a failed managed one | ||
| //! puts a headerless payload into the traced graph, where an owner field | ||
| //! registered as a gc-pointer offset forwards it as though it had a header. | ||
| //! | ||
| //! Dropping the `Box::into_raw` fallback at a call site is therefore a | ||
| //! question of whether that site can still run before `init_gc_subsystem`, | ||
| //! not of how reliable the hook has proven under a bench suite: the state | ||
| //! that matters is `Some(null)`, which a green benchmark never exercises. | ||
| //! | ||
| //! Layering: this module defines the function-pointer slots only. Wire-up | ||
| //! lives in `pyre-jit`. | ||
|
|
@@ -129,6 +136,70 @@ pub fn try_gc_alloc(type_id: u32, payload_size: usize) -> Option<*mut u8> { | |
| GC_ALLOC_HOOK.get().map(|f| f(type_id, payload_size)) | ||
| } | ||
|
|
||
| /// What an allocation hook answered, with the two non-pointer states kept | ||
| /// apart. | ||
| /// | ||
| /// `malloc_fixedsize` (incminimark.py:640-693) has neither state. The GC is a | ||
| /// prebuilt constant (framework.py:254), so a route always exists, and a | ||
| /// nursery that cannot satisfy the request reaches `collect_and_reserve` | ||
| /// (incminimark.py:981-985), which raises MemoryError rather than handing a | ||
| /// null back. Both states are pyre's own, and they are not interchangeable: | ||
| /// | ||
| /// * [`NoRoute`](Self::NoRoute) — nothing owns the heap yet: a bare unit | ||
| /// test, the pre-`init_gc_subsystem` bootstrap, or a build with no backend. | ||
| /// The caller's `malloc_raw` path *is* the whole heap there, so taking it | ||
| /// keeps the object graph consistent. | ||
| /// * [`Failed`](Self::Failed) — a GC owns the heap and could not satisfy the | ||
| /// request. `malloc_raw` is the wrong answer now: the object would hold | ||
| /// managed references the collector never traces or forwards, and its | ||
| /// missing header lets a type-id witness misread the words before it. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum GcAllocOutcome { | ||
| Allocated(*mut u8), | ||
| Failed, | ||
| NoRoute, | ||
| } | ||
|
|
||
| impl GcAllocOutcome { | ||
| /// Classify a hook result: `None` is [`NoRoute`](Self::NoRoute), | ||
| /// `Some(null)` is [`Failed`](Self::Failed). | ||
| #[inline] | ||
| pub fn from_hook(result: Option<*mut u8>) -> Self { | ||
| match result { | ||
| Some(raw) if !raw.is_null() => Self::Allocated(raw), | ||
| Some(_) => Self::Failed, | ||
| None => Self::NoRoute, | ||
| } | ||
| } | ||
|
|
||
| /// The allocated pointer, or `None` for [`NoRoute`](Self::NoRoute) so the | ||
| /// caller takes its own non-GC path. A [`Failed`](Self::Failed) does not | ||
| /// return: see [`gc_alloc_failed`]. | ||
| #[inline] | ||
| pub fn allocated_or_abort(self, payload_size: usize) -> Option<*mut u8> { | ||
| match self { | ||
| Self::Allocated(raw) => Some(raw), | ||
| Self::Failed => gc_alloc_failed(payload_size), | ||
| Self::NoRoute => None, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// A GC that owns the heap could not satisfy an allocation. | ||
| /// | ||
| /// `collect_and_reserve` (incminimark.py:981-985) raises MemoryError at this | ||
| /// point, so no caller of `malloc_fixedsize` observes a null. The pyre callers | ||
| /// return a bare pointer and run under JIT frames that cannot unwind, so the | ||
| /// failure aborts instead — the answer `alloc_typed_items_block_nursery` | ||
| /// (`object_array.rs`) already gives for a digit array whose allocation fails. | ||
| #[cold] | ||
| #[inline(never)] | ||
| pub fn gc_alloc_failed(payload_size: usize) -> ! { | ||
| let layout = std::alloc::Layout::from_size_align(payload_size, std::mem::align_of::<usize>()) | ||
| .unwrap_or_else(|_| std::alloc::Layout::new::<usize>()); | ||
| std::alloc::handle_alloc_error(layout) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an active-GC allocation returns null—for example, after a nursery-full allocation cannot spill to old-gen—this non-returning call terminates the process rather than producing the AGENTS.md reference: AGENTS.md:L14-L20 Useful? React with 👍 / 👎. |
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /// Install the `malloc_fast` allocation callback. | ||
| pub fn register_gc_alloc_fast_hook(hook: GcAllocFastHookFn) { | ||
| GC_ALLOC_FAST_HOOK.set(Some(hook)); | ||
|
|
@@ -244,6 +315,19 @@ pub fn try_gc_alloc_stable_raw(type_id: u32, payload_size: usize) -> *mut u8 { | |
| try_gc_alloc_stable(type_id, payload_size).unwrap_or(core::ptr::null_mut()) | ||
| } | ||
|
|
||
| /// [`try_gc_alloc_stable_raw`] for a caller whose fallback is `malloc_raw`. | ||
| /// | ||
| /// Returns null only for [`GcAllocOutcome::NoRoute`]; a GC that owns the heap | ||
| /// and then fails aborts rather than letting the caller substitute an untraced | ||
| /// object. Keeps the raw return so the residualised call carries no | ||
| /// discriminant, for the reason [`try_gc_alloc_stable_raw`] documents. | ||
| #[majit_macros::dont_look_inside] | ||
| pub fn try_gc_alloc_stable_or_abort(type_id: u32, payload_size: usize) -> *mut u8 { | ||
| GcAllocOutcome::from_hook(try_gc_alloc_stable(type_id, payload_size)) | ||
| .allocated_or_abort(payload_size) | ||
| .unwrap_or(core::ptr::null_mut()) | ||
| } | ||
|
|
||
| majit_gc::global_hook!(static GC_ALLOC_COLLECTING_HOOK: GcAllocHookFn); | ||
|
|
||
| /// Install the *collecting* nursery allocation callback. | ||
|
|
@@ -881,6 +965,41 @@ mod tests { | |
| clear_gc_alloc_hook(); | ||
| } | ||
|
|
||
| #[test] | ||
| fn outcome_separates_no_route_from_failure() { | ||
| let mut probe = 0u8; | ||
| assert_eq!(GcAllocOutcome::from_hook(None), GcAllocOutcome::NoRoute); | ||
| assert_eq!( | ||
| GcAllocOutcome::from_hook(Some(std::ptr::null_mut())), | ||
| GcAllocOutcome::Failed | ||
| ); | ||
| assert_eq!( | ||
| GcAllocOutcome::from_hook(Some(&mut probe as *mut u8)), | ||
| GcAllocOutcome::Allocated(&mut probe as *mut u8) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn only_no_route_returns_the_caller_to_its_raw_path() { | ||
| // `Failed` does not return at all, so the surviving `None` is the sole | ||
| // licence for a `malloc_raw` fallback. | ||
| assert!(GcAllocOutcome::NoRoute.allocated_or_abort(24).is_none()); | ||
| let mut probe = 0u8; | ||
| assert_eq!( | ||
| GcAllocOutcome::Allocated(&mut probe as *mut u8).allocated_or_abort(24), | ||
| Some(&mut probe as *mut u8) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn installed_hook_returning_null_classifies_as_failure_not_no_route() { | ||
| let _hook_lock = hook_test_guard(); | ||
| register_gc_alloc_hook(null_hook); | ||
| let outcome = GcAllocOutcome::from_hook(unsafe { try_gc_alloc_fast(1, 8) }); | ||
| clear_gc_alloc_hook(); | ||
| assert_eq!(outcome, GcAllocOutcome::Failed); | ||
| } | ||
|
Comment on lines
+994
to
+1001
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
git rev-parse --show-toplevel >/dev/null
rg -n -C 3 --glob '*.rs' 'GC_ALLOC_FAST_HOOK|clear_gc_alloc_fast_hook|register_gc_alloc_fast_hook|fn try_gc_alloc_fast\b' pyre majitRepository: youknowone/pyre Length of output: 2753 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== gc_hook outline relevant =="
ast-grep outline pyre/pyre-object/src/gc_hook.rs --view expanded | sed -n '1,220p'
echo "== gc_hook lines 80-250 =="
sed -n '80,250p' pyre/pyre-object/src/gc_hook.rs | nl -ba -v80
echo "== tests around hook =="
sed -n '960,1025p' pyre/pyre-object/src/gc_hook.rs | nl -ba -v960
echo "== sibling hooks and tests =="
rg -n -C 4 'clear_gc_alloc_hook|clear_gc_alloc_with_placement_hook|no_collect_placement_hook_has_conservative_fallback|installed_hook_returning_null' pyre/pyre-object/src/gc_hook.rs
echo "== fast hook usages outside pyre-object =="
rg -n -C 3 --glob '*.rs' 'register_gc_alloc_fast_hook|clear_gc_alloc_fast_hook|try_gc_alloc_fast' pyre majitRepository: youknowone/pyre Length of output: 5933 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== gc_hook lines 80-250 =="
sed -n '80,250p' pyre/pyre-object/src/gc_hook.rs
echo "== tests around hook and sibling fallback =="
sed -n '960,1100p' pyre/pyre-object/src/gc_hook.rs
echo "== fast hook usages outside pyre-object =="
rg -n -C 3 --glob '*.rs' 'register_gc_alloc_fast_hook|clear_gc_alloc_fast_hook|try_gc_alloc_fast' pyre majit
echo "== all fast-allocation hook registrations/clears in tracked Rust =="
rg -n --glob '*.rs' 'register_gc_alloc_fast_hook|clear_gc_alloc_fast_hook' .Repository: youknowone/pyre Length of output: 18051 Clear the fast-allocation hook before asserting the classification.
♻️ Proposed test hardening fn installed_hook_returning_null_classifies_as_failure_not_no_route() {
let _hook_lock = hook_test_guard();
+ clear_gc_alloc_fast_hook();
register_gc_alloc_hook(null_hook);
let outcome = Gc_allocOutcome::from_hook(unsafe { try_gc_alloc_fast(1, 8) });
clear_gc_alloc_hook();
assert_eq!(outcome, GcAllocOutcome::Failed);
}🤖 Prompt for AI Agents |
||
|
|
||
| #[test] | ||
| fn managed_bigint_digits_do_not_fall_back_to_raw_after_hook_failure() { | ||
| let _hook_lock = hook_test_guard(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.