Skip to content

Commit 66f3316

Browse files
committed
recycle empty arenas in arena2 and mempool3 allocator
1 parent d6efff6 commit 66f3316

File tree

11 files changed

+286
-24
lines changed

11 files changed

+286
-24
lines changed

oscars/src/alloc/arena2/alloc.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ impl<T> TaggedPtr<T> {
115115
}
116116

117117
fn as_ptr(&self) -> *mut T {
118-
self.0.map_addr(|addr| addr ^ MASK)
118+
self.0.map_addr(|addr| addr & !MASK)
119119
}
120120
}
121121

@@ -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,18 @@ 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+
self.flags.set(ArenaState::default());
409+
self.last_allocation.set(core::ptr::null_mut());
410+
self.current_offset.set(0);
411+
}
399412
}
400413

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

oscars/src/alloc/arena2/mod.rs

Lines changed: 35 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,19 @@ 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+
let dead_arenas: rust_alloc::vec::Vec<Arena> =
163+
self.arenas.extract_if(|a| a.run_drop_check()).collect();
164+
165+
for arena in dead_arenas {
166+
if self.recycled_count < MAX_RECYCLED_ARENAS {
167+
//reset in place and park in the reserve.
168+
arena.reset();
169+
self.recycled_arenas[self.recycled_count] = Some(arena);
170+
self.recycled_count += 1;
171+
} else {
172+
// Reserve is full so free this arena to the OS.
173+
drop(arena);
174+
}
143175
}
144176
}
145177

oscars/src/alloc/arena2/tests.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,95 @@ fn arc_drop() {
8888

8989
assert_eq!(allocator.arenas_len(), 0);
9090
}
91+
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+
114+
// Allocate again, must reuse the recycled arena without growing OS footprint.
115+
// heap_size returns to the same value as when a live arena was present.
116+
for i in 16..32 {
117+
let _ = allocator.try_alloc(i).unwrap();
118+
}
119+
assert_eq!(allocator.arenas_len(), 1);
120+
assert_eq!(allocator.heap_size(), heap_while_live);
121+
}
122+
123+
#[test]
124+
fn max_recycled_cap_respected() {
125+
let mut allocator = ArenaAllocator::default().with_arena_size(128);
126+
127+
let mut ptrs_per_arena: Vec<Vec<NonNull<ArenaHeapItem<u64>>>> = Vec::new();
128+
129+
for _ in 0..5 {
130+
let mut ptrs = Vec::new();
131+
let target_len = allocator.arenas_len() + 1;
132+
while allocator.arenas_len() < target_len {
133+
ptrs.push(allocator.try_alloc(0u64).unwrap().as_ptr());
134+
}
135+
ptrs_per_arena.push(ptrs);
136+
}
137+
assert_eq!(allocator.arenas_len(), 5);
138+
139+
for ptrs in ptrs_per_arena {
140+
for mut ptr in ptrs {
141+
unsafe { ptr.as_mut().mark_dropped() };
142+
}
143+
}
144+
145+
allocator.drop_dead_arenas();
146+
147+
assert_eq!(allocator.arenas_len(), 0);
148+
assert_eq!(allocator.heap_size(), 0);
149+
// The recycled list holds exactly max_recycled pages.
150+
assert_eq!(allocator.recycled_count, 4);
151+
}
152+
153+
// === test for TaggedPtr::as_ptr === //
154+
155+
// `TaggedPtr::as_ptr` must use `addr & !MASK` to unconditionally clear the high
156+
// bit rather than XORing it out. The XOR approach worked for tagged items
157+
// but incorrectly flipped the bit on untagged items, corrupting the pointer.
158+
#[test]
159+
fn as_ptr_clears_not_flips_tag_bit() {
160+
let mut allocator = ArenaAllocator::default();
161+
162+
let mut ptr_a = allocator.try_alloc(1u64).unwrap().as_ptr();
163+
let mut ptr_b = allocator.try_alloc(2u64).unwrap().as_ptr();
164+
let _ptr_c = allocator.try_alloc(3u64).unwrap().as_ptr();
165+
assert_eq!(allocator.arenas_len(), 1);
166+
167+
// Mark B and C as dropped, leave A live.
168+
unsafe {
169+
ptr_b.as_mut().mark_dropped();
170+
}
171+
172+
let states = allocator.arena_drop_states();
173+
assert_eq!(
174+
states[0].as_slice(),
175+
&[false, true, false],
176+
"item_drop_states must correctly report live/dropped status for all nodes"
177+
);
178+
179+
unsafe {
180+
ptr_a.as_mut().mark_dropped();
181+
}
182+
}

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: 48 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,27 @@ 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+
let empties: Vec<SlotPool> = self
307+
.slot_pools
308+
.extract_if(.., |p| p.run_drop_check())
309+
.collect();
310+
311+
for pool in empties {
312+
if self.recycled_pools.len() < self.max_recycled {
313+
pool.reset();
314+
self.recycled_pools.push(pool);
276315
} else {
277-
true
316+
self.current_heap_size = self.current_heap_size.saturating_sub(pool.layout.size());
278317
}
279-
});
318+
}
319+
320+
// Bump pages have no size class affinity so we always free them.
280321
self.bump_pages.retain(|p| {
281322
if p.run_drop_check() {
282323
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+
}

0 commit comments

Comments
 (0)