diff --git a/notes/2026-03-23.md b/notes/2026-03-23.md new file mode 100644 index 0000000..f9bd159 --- /dev/null +++ b/notes/2026-03-23.md @@ -0,0 +1,37 @@ +# How allocation changes affect GC policies + +Note author: shruti2522 +date: 2026-03-23 + +This note answers the question from issue #58 about how reconciling the allocation changes with different GC policies works. + +## The problem we fixed + +Before, every object had two headers. The allocator added a header to track if the object was dropped and the garbage collector added its own header. This wasted memory and tied the allocator to the GC. + +By making the allocator wrapper transparent, the allocator no longer adds a header. The only header is the GC header. This means the allocator just handles raw memory and the GC handles the rules. + +## How this helps different GC policies + +This separation is great because it means we can change the GC rules without breaking the allocator. + +### Arena allocator and mark sweep + +For our bump allocator (arena2) and mark sweep, we changed the drop tracking from a linked list to simple counters for the whole arena. This makes checking if an arena is empty very fast. But we can only track whole arenas, not single objects. + +### Mempool allocator and mark sweep + +For our size class pool (mempool3), it was already using no headers. It uses a bitmap to track slots. This lets us free single objects, which is good for incremental garbage collection. + +### Future GC policies + +Here is how this setup helps future ideas: + +1. **Generational GC:** We can recycle whole arenas for young objects when they die. Since there are no allocator headers, moving surviving objects to older generations is easy. +2. **Compacting or Copying GC:** Before this change, every object stored a raw pointer to the next object in the arena inside its `TaggedPtr` header. If you moved objects during compaction, all of those embedded pointers would become dangling. Now there are no such pointers inside allocations, so objects can be freely copied or moved in memory. +3. **Concurrent Mark:** We just need to change our counters or bitmaps to use thread safe atomic types. The transparent objects themselves are already safe. +4. **Reference Counting:** The reference count can just live in the garbage collector header. The allocator does not need to know about it. + +## Conclusion + +By removing allocator headers, the garbage collector is fully separated from the allocator. We can now easily swap them out or build new rules like compacting or generational collection. diff --git a/oscars/benches/arena2_vs_mempool3.rs b/oscars/benches/arena2_vs_mempool3.rs index f1b28eb..3d6028b 100644 --- a/oscars/benches/arena2_vs_mempool3.rs +++ b/oscars/benches/arena2_vs_mempool3.rs @@ -382,10 +382,10 @@ fn bench_dealloc_speed(c: &mut Criterion) { }, |(mut allocator, ptrs): (_, _)| { for ptr in ptrs { - let mut heap_item_ptr = ptr.as_ptr(); + let heap_item_ptr = ptr.as_ptr(); unsafe { - core::ptr::drop_in_place(heap_item_ptr.as_mut().as_ptr()); - heap_item_ptr.as_mut().mark_dropped(); + core::ptr::drop_in_place(heap_item_ptr.cast::().as_ptr()); + allocator.mark_dropped(heap_item_ptr.as_ptr() as *const u8); } } allocator.drop_dead_arenas(); diff --git a/oscars/src/alloc/arena2/alloc.rs b/oscars/src/alloc/arena2/alloc.rs index e06a826..21457c4 100644 --- a/oscars/src/alloc/arena2/alloc.rs +++ b/oscars/src/alloc/arena2/alloc.rs @@ -1,47 +1,29 @@ -use core::{ - cell::Cell, - marker::PhantomData, - ptr::{NonNull, drop_in_place}, -}; +use core::{cell::Cell, marker::PhantomData, ptr::NonNull}; use rust_alloc::alloc::{Layout, alloc, dealloc, handle_alloc_error}; use crate::alloc::arena2::ArenaAllocError; +/// Transparent wrapper for a GC value. +/// Drop state is tracked by the GC header and arena counters. #[derive(Debug)] -#[repr(C)] -pub struct ArenaHeapItem { - next: TaggedPtr, - value: T, -} +#[repr(transparent)] +pub struct ArenaHeapItem(pub T); impl ArenaHeapItem { - fn new(next: *mut ErasedHeapItem, value: T) -> Self + fn new(value: T) -> Self where T: Sized, { - Self { - next: TaggedPtr(next), - value, - } - } - - pub fn mark_dropped(&mut self) { - if !self.next.is_tagged() { - self.next.tag() - } - } - - pub fn is_dropped(&self) -> bool { - self.next.is_tagged() + Self(value) } pub fn value(&self) -> &T { - &self.value + &self.0 } pub fn as_ptr(&mut self) -> *mut T { - &mut self.value as *mut T + &mut self.0 as *mut T } /// Returns a raw mutable pointer to the value @@ -49,76 +31,29 @@ impl ArenaHeapItem { /// This avoids creating a `&mut self` reference, which can lead to stacked borrows /// if shared references to the heap item exist pub(crate) fn as_value_ptr(ptr: NonNull) -> *mut T { - // SAFETY: `&raw mut` computes the field address without creating a reference - unsafe { &raw mut (*ptr.as_ptr()).value } - } - - fn value_mut(&mut self) -> &mut T { - &mut self.value - } -} - -impl Drop for ArenaHeapItem { - fn drop(&mut self) { - unsafe { - if !self.is_dropped() { - self.mark_dropped(); - drop_in_place(self.value_mut()) - } - } + // With repr(transparent), the outer struct has the same address as the inner value + ptr.as_ptr() as *mut T } } +/// Type erased pointer for arena allocations. #[derive(Debug, Clone, Copy)] -#[repr(C)] -pub struct ErasedHeapItem { - next: TaggedPtr, - buf: NonNull, // Start of a byte buffer -} +#[repr(transparent)] +pub struct ErasedHeapItem(NonNull); impl ErasedHeapItem { pub fn get(&self) -> NonNull { - self.buf.cast::() - } - - pub fn mark_dropped(&mut self) { - if !self.next.is_tagged() { - self.next.tag() - } - } - - pub fn is_dropped(&self) -> bool { - self.next.is_tagged() + self.0.cast::() } } impl core::convert::AsRef for ErasedHeapItem { fn as_ref(&self) -> &T { - // SAFETY: TODO + // SAFETY: caller ensures this pointer was allocated as T unsafe { self.get().as_ref() } } } -const MASK: usize = 1usize << (usize::BITS as usize - 1usize); - -#[derive(Debug, Clone, Copy)] -#[repr(transparent)] -pub struct TaggedPtr(*mut T); - -impl TaggedPtr { - fn tag(&mut self) { - self.0 = self.0.map_addr(|addr| addr | MASK); - } - - fn is_tagged(&self) -> bool { - self.0 as usize & MASK == MASK - } - - fn as_ptr(&self) -> *mut T { - self.0.map_addr(|addr| addr & !MASK) - } -} - // An arena pointer // // NOTE: This will actually need to be an offset at some point if we were to add @@ -127,18 +62,19 @@ impl TaggedPtr { #[derive(Debug, Clone, Copy)] #[repr(transparent)] -pub struct ErasedArenaPointer<'arena>(NonNull, PhantomData<&'arena ()>); +pub struct ErasedArenaPointer<'arena>(NonNull, PhantomData<&'arena ()>); impl<'arena> ErasedArenaPointer<'arena> { - fn from_raw(raw: NonNull) -> Self { + fn from_raw(raw: NonNull) -> Self { Self(raw, PhantomData) } pub fn as_non_null(&self) -> NonNull { - self.0 + // Keep the old erased pointer API + ErasedHeapItem(self.0).get() } - pub fn as_raw_ptr(&self) -> *mut ErasedHeapItem { + pub fn as_raw_ptr(&self) -> *mut u8 { self.0.as_ptr() } @@ -168,17 +104,14 @@ pub struct ArenaPointer<'arena, T>(ErasedArenaPointer<'arena>, PhantomData<&'are impl<'arena, T> ArenaPointer<'arena, T> { unsafe fn from_raw(raw: NonNull>) -> Self { - Self( - ErasedArenaPointer::from_raw(raw.cast::()), - PhantomData, - ) + Self(ErasedArenaPointer::from_raw(raw.cast::()), PhantomData) } pub fn as_inner_ref(&self) -> &'arena T { - // SAFETY: HeapItem is non-null and valid for dereferencing. + // SAFETY: pointer is valid, ArenaHeapItem is repr(transparent) over T. unsafe { let typed_ptr = self.0.as_raw_ptr().cast::>(); - &(*typed_ptr).value + &(*typed_ptr).0 } } @@ -189,7 +122,7 @@ impl<'arena, T> ArenaPointer<'arena, T> { /// - Caller must ensure that T is not dropped /// - Caller must ensure that the lifetime of T does not exceed it's Arena. pub fn as_ptr(&self) -> NonNull> { - self.0.as_non_null().cast::>() + self.0.0.cast::>() } /// Convert the current ArenaPointer into an `ErasedArenaPointer` @@ -242,7 +175,10 @@ pub struct ArenaAllocationData { pub struct Arena<'arena> { pub flags: Cell, pub layout: Layout, - pub last_allocation: Cell<*mut ErasedHeapItem>, + /// Number of allocations made in this arena + alloc_count: Cell, + /// Number of items marked as dropped + drop_count: Cell, pub current_offset: Cell, pub buffer: NonNull, _marker: PhantomData<&'arena ()>, @@ -266,7 +202,8 @@ impl<'arena> Arena<'arena> { Ok(Self { flags: Cell::new(ArenaState::default()), layout, - last_allocation: Cell::new(core::ptr::null_mut::()), // NOTE: watch this one. + alloc_count: Cell::new(0), + drop_count: Cell::new(0), current_offset: Cell::new(0), buffer: data, _marker: PhantomData, @@ -277,6 +214,11 @@ impl<'arena> Arena<'arena> { self.flags.set(self.flags.get().full()); } + /// Increment the drop counter. + pub fn mark_dropped(&self) { + self.drop_count.set(self.drop_count.get() + 1); + } + pub fn alloc(&self, value: T) -> ArenaPointer<'arena, T> { self.try_alloc(value).unwrap() } @@ -328,11 +270,11 @@ impl<'arena> Arena<'arena> { let dst = buffer_ptr .add(allocation_data.buffer_offset) .cast::>(); - // NOTE: everyI recomm next begin by pointing back to the start of the buffer rather than null. - let arena_heap_item = ArenaHeapItem::new(self.last_allocation.get(), value); + // Write the value + let arena_heap_item = ArenaHeapItem::new(value); dst.write(arena_heap_item); - // We've written the last_allocation to the heap, so update with a pointer to dst - self.last_allocation.set(dst as *mut ErasedHeapItem); + // Track live/drop state with counters. + self.alloc_count.set(self.alloc_count.get() + 1); ArenaPointer::from_raw(NonNull::new_unchecked(dst)) } } @@ -372,30 +314,9 @@ impl<'arena> Arena<'arena> { }) } - /// Walks the Arena allocations to determine if the arena is droppable + /// Returns true when all allocations were marked dropped. pub fn run_drop_check(&self) -> bool { - let mut unchecked_ptr = self.last_allocation.get(); - while let Some(node) = NonNull::new(unchecked_ptr) { - let item = unsafe { node.as_ref() }; - if !item.is_dropped() { - return false; - } - unchecked_ptr = item.next.as_ptr() as *mut ErasedHeapItem - } - true - } - - // checks dropped items in this arena - #[cfg(test)] - pub fn item_drop_states(&self) -> rust_alloc::vec::Vec { - let mut result = rust_alloc::vec::Vec::new(); - let mut unchecked_ptr = self.last_allocation.get(); - while let Some(node) = NonNull::new(unchecked_ptr) { - let item = unsafe { node.as_ref() }; - result.push(item.is_dropped()); - unchecked_ptr = item.next.as_ptr() as *mut ErasedHeapItem - } - result + self.alloc_count.get() == self.drop_count.get() } /// Reset arena to its initial empty state, reusing the existing OS buffer. @@ -410,7 +331,8 @@ impl<'arena> Arena<'arena> { // the same layout in try_init. unsafe { core::ptr::write_bytes(self.buffer.as_ptr(), 0, self.layout.size()) }; self.flags.set(ArenaState::default()); - self.last_allocation.set(core::ptr::null_mut()); + self.alloc_count.set(0); + self.drop_count.set(0); self.current_offset.set(0); } } diff --git a/oscars/src/alloc/arena2/mod.rs b/oscars/src/alloc/arena2/mod.rs index 9dbde0c..8dde175 100644 --- a/oscars/src/alloc/arena2/mod.rs +++ b/oscars/src/alloc/arena2/mod.rs @@ -193,12 +193,34 @@ impl<'alloc> ArenaAllocator<'alloc> { } } - // checks dropped items across all arenas - #[cfg(test)] - pub fn arena_drop_states(&self) -> rust_alloc::vec::Vec> { - self.arenas - .iter() - .map(|arena| arena.item_drop_states()) - .collect() + /// Mark `ptr` dropped in its arena. + /// + /// # Safety + /// `ptr` must belong to this allocator and be marked once. + pub unsafe fn mark_dropped(&mut self, ptr: *const u8) { + let ptr_addr = ptr as usize; + for arena in &self.arenas { + let start = arena.buffer.as_ptr() as usize; + let end = start + arena.layout.size(); + if ptr_addr >= start && ptr_addr < end { + arena.mark_dropped(); + return; + } + } + // Recycled arenas should not match, but check anyway + for arena in self.recycled_arenas.iter().flatten() { + let start = arena.buffer.as_ptr() as usize; + let end = start + arena.layout.size(); + if ptr_addr >= start && ptr_addr < end { + arena.mark_dropped(); + return; + } + } + // Pointer not from this allocator, likely double free or foreign allocator. + debug_assert!( + false, + "mark_dropped: pointer {ptr_addr:#x} not owned by any arena; \ + possible double-free or pointer from a foreign allocator" + ); } } diff --git a/oscars/src/alloc/arena2/tests.rs b/oscars/src/alloc/arena2/tests.rs index 0d28ca2..9d3145c 100644 --- a/oscars/src/alloc/arena2/tests.rs +++ b/oscars/src/alloc/arena2/tests.rs @@ -33,7 +33,7 @@ fn alloc_dealloc() { assert_eq!(allocator.arenas_len(), 2); // Drop all the items in the first region - manual_drop(first_region); + manual_drop(&mut allocator, first_region); // Drop dead pages, only the first arena is fully dropped, the second // arena remains live because none of its items have been marked dropped. @@ -42,10 +42,10 @@ fn alloc_dealloc() { assert_eq!(allocator.arenas_len(), 1); } -fn manual_drop(region: Vec>>) { - for mut item in region { +fn manual_drop(allocator: &mut ArenaAllocator<'_>, region: Vec>>) { + for item in region { unsafe { - item.as_mut().mark_dropped(); + allocator.mark_dropped(item.as_ptr() as *const u8); } } } @@ -77,12 +77,11 @@ fn arc_drop() { assert_eq!(allocator.arenas_len(), 1); // dropping a box just runs its finalizer. - let mut heap_item = a.as_ptr(); + let heap_item = a.as_ptr(); unsafe { - let heap_item_mut = heap_item.as_mut(); // Manually drop the heap item - heap_item_mut.mark_dropped(); drop_in_place(ArenaHeapItem::as_value_ptr(heap_item)); + allocator.mark_dropped(heap_item.as_ptr() as *const u8); }; assert!(dropped.load(Ordering::SeqCst)); @@ -107,8 +106,8 @@ fn recycled_arena_avoids_realloc() { let heap_while_live = allocator.heap_size(); assert_eq!(heap_while_live, 512); - for mut ptr in ptrs { - unsafe { ptr.as_mut().mark_dropped() }; + for ptr in ptrs { + unsafe { allocator.mark_dropped(ptr.as_ptr() as *const u8) }; } allocator.drop_dead_arenas(); @@ -146,8 +145,8 @@ fn max_recycled_cap_respected() { assert_eq!(allocator.arenas_len(), 5); for ptrs in ptrs_per_arena { - for mut ptr in ptrs { - unsafe { ptr.as_mut().mark_dropped() }; + for ptr in ptrs { + unsafe { allocator.mark_dropped(ptr.as_ptr() as *const u8) }; } } @@ -159,35 +158,28 @@ fn max_recycled_cap_respected() { assert_eq!(allocator.recycled_count, 4); } -// === test for TaggedPtr::as_ptr === // +// === test for counter based drop tracking === // -// `TaggedPtr::as_ptr` must use `addr & !MASK` to unconditionally clear the high -// bit rather than XORing it out. The XOR approach worked for tagged items -// but incorrectly flipped the bit on untagged items, corrupting the pointer. +// With counter based tracking instead of linkedlist, verify that +// alloc_count and drop_count are properly tracked. #[test] -fn as_ptr_clears_not_flips_tag_bit() { +fn counter_based_drop_tracking() { let mut allocator = ArenaAllocator::default(); - let mut ptr_a = allocator.try_alloc(1u64).unwrap().as_ptr(); - let mut ptr_b = allocator.try_alloc(2u64).unwrap().as_ptr(); + let ptr_a = allocator.try_alloc(1u64).unwrap().as_ptr(); + let ptr_b = allocator.try_alloc(2u64).unwrap().as_ptr(); let _ptr_c = allocator.try_alloc(3u64).unwrap().as_ptr(); assert_eq!(allocator.arenas_len(), 1); - // Mark B and C as dropped, leave A live. + // Mark A and B as dropped (don't mark C) unsafe { - ptr_b.as_mut().mark_dropped(); + allocator.mark_dropped(ptr_a.as_ptr() as *const u8); + allocator.mark_dropped(ptr_b.as_ptr() as *const u8); } - let states = allocator.arena_drop_states(); - assert_eq!( - states[0].as_slice(), - &[false, true, false], - "item_drop_states must correctly report live/dropped status for all nodes" - ); - - unsafe { - ptr_a.as_mut().mark_dropped(); - } + // Arena should NOT be recyclable yet (C is still live) + allocator.drop_dead_arenas(); + assert_eq!(allocator.arenas_len(), 1, "arena should still be live"); } // === test for Dynamic Alignment === // @@ -248,3 +240,29 @@ fn test_alignment_upgrade_on_full_arena() { assert_eq!(addr % 512, 0); assert_eq!(allocator.arenas_len(), 3); } + +// === test for transparent wrapper overhead === // + +#[test] +fn arena_heap_item_is_transparent() { + // Verify that ArenaHeapItem has the same size as T + // This proves we eliminated the 8 byte per allocation overhead + assert_eq!( + core::mem::size_of::>(), + core::mem::size_of::(), + "ArenaHeapItem should be transparent (same size as inner type)" + ); + + assert_eq!( + core::mem::size_of::>(), + core::mem::size_of::<[u8; 128]>(), + "ArenaHeapItem should be transparent for larger types too" + ); + + // Verify alignment is preserved + assert_eq!( + core::mem::align_of::>(), + core::mem::align_of::(), + "ArenaHeapItem should preserve alignment" + ); +} diff --git a/oscars/src/collectors/mark_sweep/internals/gc_box.rs b/oscars/src/collectors/mark_sweep/internals/gc_box.rs index 031e58b..374cadb 100644 --- a/oscars/src/collectors/mark_sweep/internals/gc_box.rs +++ b/oscars/src/collectors/mark_sweep/internals/gc_box.rs @@ -91,8 +91,7 @@ impl WeakGcBox { } pub fn value(&self) -> Option<&T> { - let val = self.inner_ptr().map(|ptr| ptr.as_inner_ref().value()); - val + self.inner_ptr().map(|ptr| ptr.as_inner_ref().value()) } } diff --git a/oscars/src/collectors/mark_sweep_arena2/mod.rs b/oscars/src/collectors/mark_sweep_arena2/mod.rs index 8fc1437..300b2ed 100644 --- a/oscars/src/collectors/mark_sweep_arena2/mod.rs +++ b/oscars/src/collectors/mark_sweep_arena2/mod.rs @@ -154,41 +154,41 @@ impl MarkSweepGarbageCollector { // // NOTE: This intentionally differs from arena2's sweep_all_queues. // arena3 uses`free_slot` calls to reclaim memory. - // arena2 uses a bitmap (`mark_dropped`) and reclaims automatically + // arena2 uses counter based tracking and reclaims automatically fn sweep_all_queues(&self) { let ephemerons = core::mem::take(&mut *self.ephemeron_queue.borrow_mut()); for ephemeron in ephemerons { unsafe { - let e_ptr = ephemeron.as_ptr(); + let ptr = ephemeron.as_ptr() as *const u8; core::ptr::drop_in_place(ArenaHeapItem::as_value_ptr(ephemeron)); - (*e_ptr).mark_dropped(); + self.allocator.borrow_mut().mark_dropped(ptr); } } let roots = core::mem::take(&mut *self.root_queue.borrow_mut()); for node in roots { unsafe { - let n_ptr = node.as_ptr(); + let ptr = node.as_ptr() as *const u8; core::ptr::drop_in_place(ArenaHeapItem::as_value_ptr(node)); - (*n_ptr).mark_dropped(); + self.allocator.borrow_mut().mark_dropped(ptr); } } let pending_e = core::mem::take(&mut *self.pending_ephemeron_queue.borrow_mut()); for ephemeron in pending_e { unsafe { - let e_ptr = ephemeron.as_ptr(); + let ptr = ephemeron.as_ptr() as *const u8; core::ptr::drop_in_place(ArenaHeapItem::as_value_ptr(ephemeron)); - (*e_ptr).mark_dropped(); + self.allocator.borrow_mut().mark_dropped(ptr); } } let pending_r = core::mem::take(&mut *self.pending_root_queue.borrow_mut()); for node in pending_r { unsafe { - let n_ptr = node.as_ptr(); + let ptr = node.as_ptr() as *const u8; core::ptr::drop_in_place(ArenaHeapItem::as_value_ptr(node)); - (*n_ptr).mark_dropped(); + self.allocator.borrow_mut().mark_dropped(ptr); } } } @@ -330,8 +330,9 @@ impl MarkSweepGarbageCollector { unsafe { drop_fn(ephemeron); - let e_mut = ephemeron.as_ptr(); - (*e_mut).mark_dropped(); + self.allocator + .borrow_mut() + .mark_dropped(ephemeron.as_ptr() as *const u8); } } self.ephemeron_queue.borrow_mut().extend(still_alive); @@ -348,17 +349,18 @@ impl MarkSweepGarbageCollector { still_alive_roots.push(node); continue; } - // INVARIANT: free_slot must be called after drop_fn returns and + // INVARIANT: mark_dropped must be called after drop_fn returns and // while is_collecting is still true. Violating this would leave the - // bitmap stale for an allocation that may fire from inside drop_fn. + // counter stale for an allocation that may fire from inside drop_fn. debug_assert!( self.is_collecting.get(), - "free_slot called outside a collection — ordering invariant violated" + "mark_dropped called outside a collection — ordering invariant violated" ); unsafe { drop_fn(node); - let n_mut = node.as_ptr(); - (*n_mut).mark_dropped(); + self.allocator + .borrow_mut() + .mark_dropped(node.as_ptr() as *const u8); } } self.root_queue.borrow_mut().extend(still_alive_roots);