Skip to main content

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}