forge_alloc/layout/arena_pool.rs
1//! `ArenaPool<B, F>` — recycle reset [`BumpArena`]s across a per-commit /
2//! per-branch workload.
3//!
4//! A bump arena's natural lifecycle is "fill, then throw away". For an
5//! [`MmapBacked`](crate::MmapBacked) arena, throwing it away means `munmap`,
6//! and minting the next one means `mmap` plus a demand-zero re-fault storm on
7//! first touch. On a hot per-commit path that fixed cost dominates. An
8//! `ArenaPool` breaks the cycle: hand arenas out with [`checkout`], return them
9//! with [`give_back`] (which [`reset`](BumpArena::reset)s in O(1) and retains
10//! them up to a cap), and in steady state the same mappings are reused with
11//! **zero** `munmap` / `mmap` / re-fault.
12//!
13//! When idle arenas should not hold physical RAM (a quiet period between
14//! bursts), [`release_idle`](ArenaPool::release_idle) drops their pages via
15//! `madvise(DONTNEED)` / `MEM_RESET` while keeping the virtual reservation warm
16//! — bounding resident memory without leaving the pool. (That method requires
17//! an [`OsBacked`] backing, i.e. an mmap-family one.)
18//!
19//! ```
20//! use forge_alloc::{ArenaPool, InlineBacked};
21//!
22//! // Pool of up to 4 reset arenas, each over a fresh 64 KiB inline backing.
23//! let mut pool = ArenaPool::new(4, || Ok(InlineBacked::<65536>::new()));
24//! let arena = pool.checkout().unwrap();
25//! // ... fill `arena` for one unit of work ...
26//! pool.give_back(arena); // reset + retained for the next checkout
27//! assert_eq!(pool.idle_count(), 1);
28//! ```
29
30use alloc::vec::Vec;
31
32use forge_alloc_core::{AllocError, FixedRange, OsBacked};
33
34use super::bump::BumpArena;
35
36/// A pool of recyclable [`BumpArena`]s — hand them out with
37/// [`checkout`](Self::checkout), return them with
38/// [`give_back`](Self::give_back) (an O(1) [`reset`](BumpArena::reset) plus
39/// retain up to a cap), so in steady state the same backings are reused with no
40/// `munmap` / `mmap` / re-fault. The motivating use is a per-commit / per-branch
41/// workload where throwing arenas away per unit of work would dominate the cost.
42///
43/// `B` is the backing type; `F` mints a fresh backing on demand (e.g.
44/// `|| MmapBacked::new(32 * 1024)`). Arenas are wrapped in [`BumpArena`]
45/// internally. When idle arenas should not hold physical RAM, an [`OsBacked`]
46/// backing additionally enables [`release_idle`](Self::release_idle).
47pub struct ArenaPool<B: FixedRange, F> {
48 factory: F,
49 /// Reset, ready-to-reuse arenas. Invariant: every arena here has cursor 0
50 /// (`give_back` resets before pushing).
51 idle: Vec<BumpArena<B>>,
52 /// Maximum number of idle arenas to retain; extras are dropped on
53 /// `give_back` (the rare over-cap overflow).
54 cap: usize,
55}
56
57impl<B, F> ArenaPool<B, F>
58where
59 B: FixedRange,
60 F: FnMut() -> Result<B, AllocError>,
61{
62 /// Create an empty pool that retains up to `cap` idle arenas and mints new
63 /// backings with `factory`.
64 #[inline]
65 pub fn new(cap: usize, factory: F) -> Self {
66 Self {
67 factory,
68 idle: Vec::new(),
69 cap,
70 }
71 }
72
73 /// Check out an arena: reuse a reset idle one if available, otherwise mint a
74 /// fresh backing via the factory and wrap it. The returned arena always
75 /// starts empty (cursor 0).
76 #[inline]
77 pub fn checkout(&mut self) -> Result<BumpArena<B>, AllocError> {
78 match self.idle.pop() {
79 // Idle arenas were reset on `give_back`, so they're ready as-is.
80 Some(arena) => Ok(arena),
81 None => BumpArena::new((self.factory)()?),
82 }
83 }
84
85 /// Return an arena to the pool. It is [`reset`](BumpArena::reset) (O(1)) and
86 /// retained for reuse if the pool is below its cap; otherwise it is dropped
87 /// (releasing its backing). In steady state — checkouts and give-backs
88 /// balanced under the cap — this performs no allocation syscalls.
89 #[inline]
90 pub fn give_back(&mut self, mut arena: BumpArena<B>) {
91 arena.reset();
92 if self.idle.len() < self.cap {
93 self.idle.push(arena);
94 }
95 // else: over cap — drop `arena`, releasing its backing.
96 }
97
98 /// Pre-mint up to `n` idle arenas (clamped to the remaining cap), so the
99 /// first `checkout`s don't pay backing construction. Returns the number
100 /// actually added; stops early and returns `Err` if the factory fails.
101 pub fn prewarm(&mut self, n: usize) -> Result<usize, AllocError> {
102 let target = self.cap.min(self.idle.len().saturating_add(n));
103 let mut added = 0;
104 while self.idle.len() < target {
105 let arena = BumpArena::new((self.factory)()?)?;
106 self.idle.push(arena);
107 added += 1;
108 }
109 Ok(added)
110 }
111
112 /// Number of reset arenas currently retained and ready for checkout.
113 #[inline]
114 pub fn idle_count(&self) -> usize {
115 self.idle.len()
116 }
117
118 /// The retention cap (maximum idle arenas).
119 #[inline]
120 pub fn capacity(&self) -> usize {
121 self.cap
122 }
123
124 /// Drop all idle arenas, releasing their backings (e.g. `munmap`). Use to
125 /// reclaim address space when the pool will be idle for a long time; the
126 /// next checkout mints fresh.
127 #[inline]
128 pub fn clear(&mut self) {
129 self.idle.clear();
130 }
131}
132
133impl<B, F> ArenaPool<B, F>
134where
135 B: FixedRange + OsBacked,
136 F: FnMut() -> Result<B, AllocError>,
137{
138 /// Release the physical pages backing every idle arena
139 /// (`madvise(DONTNEED)` / `MEM_RESET`) while keeping their virtual
140 /// reservations mapped for reuse. Bounds resident memory for arenas sitting
141 /// idle without dropping or re-`mmap`ing them; the next checkout re-faults
142 /// the pages it touches.
143 pub fn release_idle(&self) {
144 for arena in &self.idle {
145 // Idle arenas are reset (cursor 0): no live allocation overlaps the
146 // region, so releasing the whole region is sound.
147 // SAFETY: `[base_ptr, base_ptr + region_size)` is the arena's entire
148 // mapping; no live allocations exist (idle + reset). `ArenaPool` is
149 // `!Sync` (inherited from `BumpArena`'s `UnsafeCell` cursor), so no
150 // concurrent `commit` races the `UnsafeCell<committed>` read inside
151 // `release_pages` on the Windows lazy-commit path. See
152 // `OsBacked::release_pages`.
153 unsafe { arena.release_pages(arena.base_ptr(), arena.region_size()) };
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::backing::InlineBacked;
162 use crate::Allocator;
163 use forge_alloc_core::NonZeroLayout;
164
165 fn inline_factory() -> Result<InlineBacked<4096>, AllocError> {
166 Ok(InlineBacked::<4096>::new())
167 }
168
169 #[test]
170 fn checkout_mints_when_empty_then_give_back_recycles() {
171 let mut pool = ArenaPool::new(2, inline_factory);
172 assert_eq!(pool.idle_count(), 0);
173
174 let arena = pool.checkout().unwrap();
175 let cap = arena.capacity();
176 // Use it, then return it.
177 let layout = NonZeroLayout::from_size_align(64, 8).unwrap();
178 let _ = arena.allocate(layout).unwrap();
179 pool.give_back(arena);
180 assert_eq!(pool.idle_count(), 1);
181
182 // Next checkout reuses the same backing, reset to empty.
183 let arena2 = pool.checkout().unwrap();
184 assert_eq!(arena2.allocated(), 0, "recycled arena must be reset");
185 assert_eq!(arena2.capacity(), cap, "same backing capacity");
186 assert_eq!(pool.idle_count(), 0);
187 }
188
189 #[test]
190 fn give_back_over_cap_drops_the_extra() {
191 let mut pool = ArenaPool::new(1, inline_factory);
192 let a = pool.checkout().unwrap();
193 let b = pool.checkout().unwrap();
194 pool.give_back(a);
195 assert_eq!(pool.idle_count(), 1);
196 // Pool is at cap; returning `b` must not exceed it.
197 pool.give_back(b);
198 assert_eq!(
199 pool.idle_count(),
200 1,
201 "over-cap give_back drops, never grows past cap"
202 );
203 }
204
205 #[test]
206 fn prewarm_mints_up_to_cap() {
207 let mut pool = ArenaPool::new(3, inline_factory);
208 let added = pool.prewarm(10).unwrap();
209 assert_eq!(added, 3, "prewarm clamps to remaining cap");
210 assert_eq!(pool.idle_count(), 3);
211 // Already full: prewarm adds nothing.
212 assert_eq!(pool.prewarm(5).unwrap(), 0);
213 }
214
215 #[test]
216 fn cap_zero_never_retains() {
217 let mut pool = ArenaPool::new(0, inline_factory);
218 let a = pool.checkout().unwrap();
219 pool.give_back(a);
220 assert_eq!(pool.idle_count(), 0, "cap=0 pool retains nothing");
221 assert_eq!(pool.prewarm(5).unwrap(), 0, "cap=0 prewarm adds nothing");
222 // Still hands out fresh arenas fine.
223 let b = pool.checkout().unwrap();
224 assert_eq!(b.allocated(), 0);
225 }
226
227 #[test]
228 fn factory_failure_propagates_cleanly() {
229 use core::cell::Cell;
230 // Factory that succeeds `ok` times then fails.
231 let budget = Cell::new(1usize);
232 let factory = || {
233 if budget.get() == 0 {
234 return Err(AllocError);
235 }
236 budget.set(budget.get() - 1);
237 Ok(InlineBacked::<4096>::new())
238 };
239 let mut pool = ArenaPool::new(4, factory);
240
241 // First checkout succeeds (budget 1 -> 0).
242 let a = pool.checkout().unwrap();
243 // Second mint fails; error propagates, pool state unchanged.
244 assert!(pool.checkout().is_err());
245 assert_eq!(pool.idle_count(), 0);
246 pool.give_back(a);
247 assert_eq!(pool.idle_count(), 1);
248 // prewarm with no budget left adds nothing and surfaces the error.
249 assert!(pool.prewarm(3).is_err());
250 }
251
252 #[test]
253 fn clear_drops_idle() {
254 let mut pool = ArenaPool::new(4, inline_factory);
255 pool.prewarm(4).unwrap();
256 assert_eq!(pool.idle_count(), 4);
257 pool.clear();
258 assert_eq!(pool.idle_count(), 0);
259 }
260
261 // release_idle needs an OsBacked backing → MmapBacked (std + unix/windows).
262 #[cfg(all(feature = "std", any(unix, windows)))]
263 #[test]
264 #[cfg_attr(miri, ignore = "miri can't shim mmap")]
265 fn release_idle_then_reuse_round_trips() {
266 use crate::backing::MmapBacked;
267 let mut pool = ArenaPool::new(2, || MmapBacked::new(64 * 1024));
268 pool.prewarm(2).unwrap();
269
270 // Release the physical pages of all idle arenas — must not crash, and
271 // the arenas must stay in the pool (still reusable).
272 pool.release_idle();
273 assert_eq!(
274 pool.idle_count(),
275 2,
276 "release_idle keeps arenas, only drops pages"
277 );
278
279 // Reuse: the mapping is still valid; a fresh allocation re-faults cleanly.
280 let arena = pool.checkout().unwrap();
281 let layout = NonZeroLayout::from_size_align(4096, 8).unwrap();
282 let block = arena.allocate(layout).unwrap();
283 unsafe {
284 core::ptr::write_bytes(block.cast::<u8>().as_ptr(), 0xCD, 4096);
285 }
286 pool.give_back(arena);
287 }
288}