Skip to content

Commit bb0eb8e

Browse files
authored
recycle empty arenas in arena2 and mempool3 allocator (#44)
* recycle empty arenas in arena2 and mempool3 allocator * recycle empty arenas in arena2 and mempool3 allocator
1 parent a47bfba commit bb0eb8e

File tree

11 files changed

+252
-23
lines changed

11 files changed

+252
-23
lines changed

oscars/src/alloc/arena2/alloc.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ impl<'arena, T> ArenaPointer<'arena, T> {
203203
///
204204
/// safe because the gc collector owns the arena and keeps it alive
205205
pub(crate) unsafe fn extend_lifetime(self) -> ArenaPointer<'static, T> {
206-
ArenaPointer(self.0.extend_lifetime(), PhantomData)
206+
// SAFETY: upheld by caller
207+
ArenaPointer(unsafe { self.0.extend_lifetime() }, PhantomData)
207208
}
208209
}
209210

@@ -396,6 +397,22 @@ impl<'arena> Arena<'arena> {
396397
}
397398
result
398399
}
400+
401+
/// Reset arena to its initial empty state, reusing the existing OS buffer.
402+
/// Must only be called when `run_drop_check()` is true (all items dropped).
403+
pub fn reset(&self) {
404+
debug_assert!(
405+
self.run_drop_check(),
406+
"reset() called on an arena with live items"
407+
);
408+
// Zero the buffer so stale object graphs are not observable after recycling.
409+
// SAFETY: buffer is valid for the full layout size and was allocated with
410+
// the same layout in try_init.
411+
unsafe { core::ptr::write_bytes(self.buffer.as_ptr(), 0, self.layout.size()) };
412+
self.flags.set(ArenaState::default());
413+
self.last_allocation.set(core::ptr::null_mut());
414+
self.current_offset.set(0);
415+
}
399416
}
400417

401418
impl<'arena> Drop for Arena<'arena> {

oscars/src/alloc/arena2/mod.rs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,18 @@ const DEFAULT_ARENA_SIZE: usize = 4096;
4848
/// Default upper limit of 2MB (2 ^ 21)
4949
const DEFAULT_HEAP_THRESHOLD: usize = 2_097_152;
5050

51+
/// Maximum number of idle arenas held (4 idle pages x 4KB = 16KB of OS memory pressure buffered)
52+
const MAX_RECYCLED_ARENAS: usize = 4;
53+
5154
#[derive(Debug)]
5255
pub struct ArenaAllocator<'alloc> {
5356
heap_threshold: usize,
5457
arena_size: usize,
5558
arenas: LinkedList<Arena<'alloc>>,
59+
// empty arenas kept alive to avoid OS reallocation on the next cycle
60+
recycled_arenas: [Option<Arena<'alloc>>; MAX_RECYCLED_ARENAS],
61+
// number of idle arenas currently held
62+
recycled_count: usize,
5663
}
5764

5865
impl<'alloc> Default for ArenaAllocator<'alloc> {
@@ -61,6 +68,8 @@ impl<'alloc> Default for ArenaAllocator<'alloc> {
6168
heap_threshold: DEFAULT_HEAP_THRESHOLD,
6269
arena_size: DEFAULT_ARENA_SIZE,
6370
arenas: LinkedList::default(),
71+
recycled_arenas: core::array::from_fn(|_| None),
72+
recycled_count: 0,
6473
}
6574
}
6675
}
@@ -80,11 +89,13 @@ impl<'alloc> ArenaAllocator<'alloc> {
8089
}
8190

8291
pub fn heap_size(&self) -> usize {
92+
// recycled arenas hold no live objects, exclude them from GC pressure
8393
self.arenas_len() * self.arena_size
8494
}
8595

8696
pub fn is_below_threshold(&self) -> bool {
87-
self.heap_size() <= self.heap_threshold - self.arena_size
97+
// saturating_sub avoids underflow when heap_threshold < arena_size
98+
self.heap_size() <= self.heap_threshold.saturating_sub(self.arena_size)
8899
}
89100

90101
pub fn increase_threshold(&mut self) {
@@ -128,6 +139,16 @@ impl<'alloc> ArenaAllocator<'alloc> {
128139
}
129140

130141
pub fn initialize_new_arena(&mut self) -> Result<(), ArenaAllocError> {
142+
// Check the recycle list first to avoid an OS allocation.
143+
if self.recycled_count > 0 {
144+
self.recycled_count -= 1;
145+
if let Some(recycled) = self.recycled_arenas[self.recycled_count].take() {
146+
// arena.reset() was already called when it was parked
147+
self.arenas.push_front(recycled);
148+
return Ok(());
149+
}
150+
}
151+
131152
let new_arena = Arena::try_init(self.arena_size, 16)?;
132153
self.arenas.push_front(new_arena);
133154
Ok(())
@@ -138,8 +159,14 @@ impl<'alloc> ArenaAllocator<'alloc> {
138159
}
139160

140161
pub fn drop_dead_arenas(&mut self) {
141-
for dead_arenas in self.arenas.extract_if(|a| a.run_drop_check()) {
142-
drop(dead_arenas)
162+
for arena in self.arenas.extract_if(|a| a.run_drop_check()) {
163+
if self.recycled_count < MAX_RECYCLED_ARENAS {
164+
//reset in place and park in the reserve.
165+
arena.reset();
166+
self.recycled_arenas[self.recycled_count] = Some(arena);
167+
self.recycled_count += 1;
168+
}
169+
// else: arena drops here, returning memory to the OS
143170
}
144171
}
145172

oscars/src/alloc/arena2/tests.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,71 @@ fn arc_drop() {
8989
assert_eq!(allocator.arenas_len(), 0);
9090
}
9191

92+
#[test]
93+
fn recycled_arena_avoids_realloc() {
94+
let mut allocator = ArenaAllocator::default().with_arena_size(512);
95+
96+
let mut ptrs = Vec::new();
97+
for i in 0..16 {
98+
ptrs.push(allocator.try_alloc(i).unwrap().as_ptr());
99+
}
100+
assert_eq!(allocator.arenas_len(), 1);
101+
// heap_size counts only live arenas, so capture it while one is active.
102+
let heap_while_live = allocator.heap_size();
103+
assert_eq!(heap_while_live, 512);
104+
105+
for mut ptr in ptrs {
106+
unsafe { ptr.as_mut().mark_dropped() };
107+
}
108+
allocator.drop_dead_arenas();
109+
110+
// After recycling, the arena is parked, no live arenas, so heap_size is 0.
111+
assert_eq!(allocator.arenas_len(), 0);
112+
assert_eq!(allocator.heap_size(), 0);
113+
// recycled_count == 1 proves the arena was parked in the recycle slot, not freed to the OS.
114+
assert_eq!(allocator.recycled_count, 1);
115+
116+
// Allocate again, must reuse the recycled arena without growing OS footprint.
117+
// heap_size returns to the same value as when a live arena was present.
118+
for i in 16..32 {
119+
let _ = allocator.try_alloc(i).unwrap();
120+
}
121+
assert_eq!(allocator.arenas_len(), 1);
122+
assert_eq!(allocator.heap_size(), heap_while_live);
123+
// recycled_count == 0 proves the recycled slot was consumed rather than a new OS allocation.
124+
assert_eq!(allocator.recycled_count, 0);
125+
}
126+
127+
#[test]
128+
fn max_recycled_cap_respected() {
129+
let mut allocator = ArenaAllocator::default().with_arena_size(128);
130+
131+
let mut ptrs_per_arena: Vec<Vec<NonNull<ArenaHeapItem<u64>>>> = Vec::new();
132+
133+
for _ in 0..5 {
134+
let mut ptrs = Vec::new();
135+
let target_len = allocator.arenas_len() + 1;
136+
while allocator.arenas_len() < target_len {
137+
ptrs.push(allocator.try_alloc(0u64).unwrap().as_ptr());
138+
}
139+
ptrs_per_arena.push(ptrs);
140+
}
141+
assert_eq!(allocator.arenas_len(), 5);
142+
143+
for ptrs in ptrs_per_arena {
144+
for mut ptr in ptrs {
145+
unsafe { ptr.as_mut().mark_dropped() };
146+
}
147+
}
148+
149+
allocator.drop_dead_arenas();
150+
151+
assert_eq!(allocator.arenas_len(), 0);
152+
assert_eq!(allocator.heap_size(), 0);
153+
// The recycled list holds exactly max_recycled pages.
154+
assert_eq!(allocator.recycled_count, 4);
155+
}
156+
92157
// === test for TaggedPtr::as_ptr === //
93158

94159
// `TaggedPtr::as_ptr` must use `addr & !MASK` to unconditionally clear the high

oscars/src/alloc/mempool3/alloc.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,29 @@ impl SlotPool {
277277
pub fn run_drop_check(&self) -> bool {
278278
self.live.get() == 0
279279
}
280+
281+
/// Reset this pool to the empty state it had after `try_init`, reusing the
282+
/// existing OS buffer. Must only be called when `run_drop_check()` is true.
283+
///
284+
/// After `reset()` the pool is ready for `alloc_slot` without any further
285+
/// OS interaction, avoiding a round trip through the global allocator.
286+
pub fn reset(&self) {
287+
debug_assert_eq!(
288+
self.live.get(),
289+
0,
290+
"reset() called on a non-empty SlotPool (live = {})",
291+
self.live.get()
292+
);
293+
// Clear the bitmap so all slots become free again.
294+
//
295+
// SAFETY: buffer is valid for at least `bitmap_bytes` and was
296+
// originally zero initialised in try_init with the same length.
297+
unsafe {
298+
core::ptr::write_bytes(self.buffer.as_ptr(), 0, self.bitmap_bytes);
299+
}
300+
self.bump.set(0);
301+
self.free_list.set(None);
302+
}
280303
}
281304

282305
impl Drop for SlotPool {

oscars/src/alloc/mempool3/mod.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ pub struct PoolAllocator<'alloc> {
5656
pub(crate) free_cache: Cell<usize>,
5757
// per size class cached index of the last pool used by alloc_slot
5858
pub(crate) alloc_cache: [Cell<usize>; 12],
59+
// empty slot pools kept alive to avoid OS reallocation on the next cycle
60+
pub(crate) recycled_pools: Vec<SlotPool>,
61+
// maximum number of idle pages held across all size classes
62+
pub(crate) max_recycled: usize,
5963
_marker: core::marker::PhantomData<&'alloc ()>,
6064
}
6165

@@ -82,6 +86,9 @@ impl<'alloc> Default for PoolAllocator<'alloc> {
8286
Cell::new(usize::MAX),
8387
Cell::new(usize::MAX),
8488
],
89+
recycled_pools: Vec::new(),
90+
// one idle page per size class keeps memory pressure manageable
91+
max_recycled: SIZE_CLASSES.len(),
8592
_marker: core::marker::PhantomData,
8693
}
8794
}
@@ -155,6 +162,29 @@ impl<'alloc> PoolAllocator<'alloc> {
155162
}
156163

157164
// need a new pool for this size class
165+
// try the recycle list first
166+
// to avoid a round trip through the OS allocator
167+
if let Some(pos) = self
168+
.recycled_pools
169+
.iter()
170+
.rposition(|p| p.slot_size == slot_size)
171+
{
172+
let pool = self.recycled_pools.swap_remove(pos);
173+
// pool.reset() was already called in drop_empty_pools when it was parked
174+
let slot_ptr = pool.alloc_slot().ok_or(PoolAllocError::OutOfMemory)?;
175+
let insert_idx = self.slot_pools.len();
176+
self.slot_pools.push(pool);
177+
self.alloc_cache[sc_idx].set(insert_idx);
178+
179+
// SAFETY: slot_ptr was successfully allocated for this size class
180+
return unsafe {
181+
let dst = slot_ptr.as_ptr() as *mut PoolItem<T>;
182+
dst.write(PoolItem(value));
183+
Ok(PoolPointer::from_raw(NonNull::new_unchecked(dst)))
184+
};
185+
}
186+
187+
// Recycle list had no match, allocate a fresh page from the OS.
158188
let total = self.page_size.max(slot_size * 4);
159189
let new_pool = SlotPool::try_init(slot_size, total, 16)?;
160190
self.current_heap_size += new_pool.layout.size();
@@ -267,16 +297,22 @@ impl<'alloc> PoolAllocator<'alloc> {
267297
false
268298
}
269299

270-
/// drop empty slot pools and bump pages
300+
/// Reclaim slot pool pages that became empty after a GC sweep.
301+
///
302+
/// Empty pages are parked in a recycle list (up to `max_recycled`)
303+
/// to avoid global allocator round trips on the next allocation.
271304
pub fn drop_empty_pools(&mut self) {
272-
self.slot_pools.retain(|p| {
273-
if p.run_drop_check() {
274-
self.current_heap_size = self.current_heap_size.saturating_sub(p.layout.size());
275-
false
305+
// Drain fully empty slot pools into the recycle list.
306+
for pool in self.slot_pools.extract_if(.., |p| p.run_drop_check()) {
307+
if self.recycled_pools.len() < self.max_recycled {
308+
pool.reset();
309+
self.recycled_pools.push(pool);
276310
} else {
277-
true
311+
self.current_heap_size = self.current_heap_size.saturating_sub(pool.layout.size());
278312
}
279-
});
313+
}
314+
315+
// Bump pages have no size class affinity so we always free them.
280316
self.bump_pages.retain(|p| {
281317
if p.run_drop_check() {
282318
self.current_heap_size = self.current_heap_size.saturating_sub(p.layout.size());

oscars/src/alloc/mempool3/tests.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,64 @@ fn slot_count_tight_capacity() {
173173
assert_eq!(bitmap_bytes, 8);
174174
assert_eq!(slot_count, 7);
175175
}
176+
177+
/// Verify that recycled empty slot pools are reused on the next `try_alloc`
178+
/// without allocating new OS memory, the heap_size should be unchanged.
179+
#[test]
180+
fn recycled_pool_avoids_realloc() {
181+
let mut allocator = PoolAllocator::default().with_page_size(4096);
182+
183+
let ptrs: Vec<_> = (0u64..16)
184+
.map(|i| allocator.try_alloc(i).unwrap().as_ptr())
185+
.collect();
186+
assert_eq!(allocator.slot_pools.len(), 1);
187+
let heap_after_first_alloc = allocator.current_heap_size;
188+
189+
for ptr in ptrs {
190+
allocator.free_slot(ptr.cast::<u8>());
191+
}
192+
allocator.drop_empty_pools();
193+
194+
assert_eq!(allocator.slot_pools.len(), 0);
195+
assert_eq!(allocator.recycled_pools.len(), 1);
196+
assert_eq!(allocator.current_heap_size, heap_after_first_alloc);
197+
198+
let heap_before_second_alloc = allocator.current_heap_size;
199+
for i in 16u64..32 {
200+
let _ = allocator.try_alloc(i).unwrap();
201+
}
202+
203+
assert_eq!(allocator.slot_pools.len(), 1);
204+
assert_eq!(allocator.recycled_pools.len(), 0);
205+
assert_eq!(allocator.current_heap_size, heap_before_second_alloc);
206+
}
207+
208+
/// Verify that when more pools become empty than `max_recycled` allows,
209+
/// the overflow is freed to the OS.
210+
#[test]
211+
fn max_recycled_cap_respected() {
212+
let mut allocator = PoolAllocator::default().with_page_size(32);
213+
allocator.max_recycled = 0;
214+
215+
let p1 = allocator.try_alloc(1u64).unwrap().as_ptr();
216+
let px = allocator.try_alloc(2u64).unwrap().as_ptr();
217+
let py = allocator.try_alloc(3u64).unwrap().as_ptr();
218+
assert_eq!(allocator.slot_pools.len(), 1);
219+
220+
let p2 = allocator.try_alloc(4u64).unwrap().as_ptr();
221+
assert_eq!(allocator.slot_pools.len(), 2);
222+
223+
let heap_before = allocator.current_heap_size;
224+
225+
allocator.free_slot(p1.cast::<u8>());
226+
allocator.free_slot(px.cast::<u8>());
227+
allocator.free_slot(py.cast::<u8>());
228+
allocator.free_slot(p2.cast::<u8>());
229+
230+
allocator.max_recycled = 1;
231+
allocator.drop_empty_pools();
232+
233+
assert_eq!(allocator.slot_pools.len(), 0);
234+
assert_eq!(allocator.recycled_pools.len(), 1);
235+
assert!(allocator.current_heap_size < heap_before);
236+
}

oscars/src/collectors/mark_sweep/cell.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ impl<T: ?Sized> GcRefCell<T> {
172172
}
173173

174174
// returns a raw pointer to the inner value or `None` if currently mutably borrowed
175+
#[allow(dead_code)]
175176
pub(crate) fn get_raw(&self) -> Option<*mut T> {
176177
match self.borrow.get().borrowed() {
177178
BorrowState::Writing => None,

oscars/src/collectors/mark_sweep/mod.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,6 @@ impl MarkSweepGarbageCollector {
163163
// memory so we can still inspect the trace color on ephemerons;
164164
// use sweep_color since alive objects were marked with it.
165165
self.sweep_trace_color(sweep_color);
166-
167-
// finally tell the allocator to reclaim raw OS memory
168-
// from arenas that are completely empty now
169-
self.allocator.borrow_mut().drop_empty_pools();
170166
}
171167

172168
// Force drops all elements in the internal tracking queues and clears
@@ -235,8 +231,9 @@ impl MarkSweepGarbageCollector {
235231
let new_color = sweep_color.flip();
236232
self.trace_color.set(new_color);
237233

238-
// NOTE: It would actually be interesting to reuse the pools that are empty rather
239-
// than drop the page and reallocate when a new page is needed ... TBD
234+
// Reclaim OS memory from pool pages that became fully empty during the sweep above.
235+
// Empty pool pages are parked in a recycle list rather than immediately freed to the OS,
236+
// allowing the next try_alloc to pull from that list and avoid OS allocation thrashing.
240237
self.allocator.borrow_mut().drop_empty_pools();
241238

242239
// Drain pending queues while `is_collecting` is still true so that any

oscars/src/collectors/mark_sweep_arena2/internals/ephemeron.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ impl<K: Trace, V: Trace> Ephemeron<K, V> {
3232
}
3333
}
3434

35+
#[allow(dead_code)] // TODO: figure out what to do with this
3536
pub fn key(&self) -> &K {
3637
self.key.value()
3738
}

0 commit comments

Comments
 (0)