Skip to main content

crcbl_core/
alloc.rs

1//! Frame-scoped bump allocation.
2//!
3//! A [`FrameArena`] is a fixed block of bytes with a bump pointer. Per-frame
4//! transient data — visible-instance lists, draw arguments, scratch index
5//! buffers — is carved out of it and thrown away wholesale by [`FrameArena::reset`]
6//! at the frame boundary. No per-allocation bookkeeping, no free, no
7//! fragmentation.
8
9use core::cell::Cell;
10use core::mem;
11use core::ptr::NonNull;
12use core::slice;
13
14/// Alignment of the arena's backing block, and the largest alignment it can
15/// satisfy. 64 bytes covers a cache line and every SIMD type the engine is
16/// likely to bump-allocate (including AVX-512 vectors).
17const BLOCK_ALIGN: usize = 64;
18
19/// One unit of backing storage. `Vec<u8>` would only guarantee 1-byte
20/// alignment for the block itself, which no amount of offset alignment can
21/// repair, so the block is a `Vec` of over-aligned chunks instead.
22#[derive(Clone, Copy)]
23#[repr(C, align(64))]
24struct Block([u8; BLOCK_ALIGN]);
25
26/// Frame budget observability — see [`FrameArena::stats`].
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
28pub struct ArenaStats {
29    /// Bytes handed out since the last [`FrameArena::reset`].
30    pub used: usize,
31    /// Total bytes the arena can hand out.
32    pub capacity: usize,
33    /// Largest `used` seen since the arena was created (or since
34    /// [`FrameArena::reset_peak`]). This is the number that tells you whether
35    /// the frame budget is right.
36    pub peak: usize,
37}
38
39impl ArenaStats {
40    /// Fraction of capacity consumed at the high-water mark, in `0.0..=1.0`.
41    #[must_use]
42    pub fn peak_fraction(&self) -> f32 {
43        if self.capacity == 0 {
44            0.0
45        } else {
46            self.peak as f32 / self.capacity as f32
47        }
48    }
49}
50
51/// A fixed-capacity bump allocator for per-frame data.
52///
53/// # Growth policy
54///
55/// The arena **never grows**. Exhaustion is a hard error: the allocating
56/// methods panic and the `try_` variants return `None`. Two reasons:
57///
58/// * A frame arena that grows on demand cannot be distinguished from a leak.
59///   The whole point is that per-frame allocation is *bounded*; if the bound is
60///   soft, the first system that forgets to clamp a list quietly turns into a
61///   memory-growth bug that only shows up as a stall days later.
62/// * Growth would invalidate outstanding slices, so a growing arena needs
63///   either chunk chaining or `unsafe` aliasing games. Neither is worth it at
64///   this layer.
65///
66/// The peak in [`FrameArena::stats`] is the tool for sizing: run the game, read
67/// the high-water mark, set the capacity with headroom. Systems that can
68/// legitimately degrade (a debug overlay, a particle burst) should use
69/// [`FrameArena::try_alloc_slice`] and drop work when the budget is gone rather
70/// than take the process down.
71///
72/// # Borrowing
73///
74/// Allocation takes `&self` and returns `&mut [T]` borrowed from the arena, so
75/// **any number of allocations can be live at once** — which is the actual
76/// usage: a frame system builds a visible-instance list *and* a draw-argument
77/// list *and* an index list, and holds all three until the encoder consumes
78/// them. [`FrameArena::reset`] takes `&mut self`, so the borrow checker is what
79/// proves no slice outlives its frame; the frame boundary needs no discipline
80/// beyond calling `reset`.
81///
82/// ```
83/// use crcbl_core::alloc::FrameArena;
84///
85/// let mut arena = FrameArena::with_capacity(4096);
86/// let instances = arena.alloc_slice(3, [0.0f32; 4]);
87/// let draw_args = arena.alloc_slice(2, 0u32);
88/// instances[0][1] = 1.0;
89/// draw_args[1] = 7;
90/// // Both live at once; `reset` cannot be called until both are dropped.
91/// assert_eq!(instances[0][1] + draw_args[1] as f32, 8.0);
92/// arena.reset();
93/// ```
94///
95/// # Aliasing
96///
97/// This is the `bumpalo` model, and handing `&mut` out of `&self` rests on
98/// three facts:
99///
100/// 1. **Regions are disjoint by construction.** The bump offset only ever
101///    advances between resets, so no two allocations can return overlapping
102///    byte ranges. Every returned slice is the unique reference to its bytes.
103/// 2. **The block never moves and never grows.** Capacity is fixed at
104///    construction and the backing `Vec` is *never reborrowed afterwards* —
105///    every allocation derives from `base`, a pointer taken once in
106///    `with_capacity`. (Calling `Vec::as_mut_ptr` again would create a fresh
107///    unique reference to the whole block and invalidate every slice already
108///    handed out.)
109/// 3. **`reset` and `reset_peak` take `&mut self`.** An exclusive borrow of
110///    the arena cannot coexist with any slice borrowed from it, so memory can
111///    only be recycled once every reference to it is gone. `T: Copy` also means
112///    no allocation has drop glue to skip.
113///
114/// The arena is neither `Send` nor `Sync`: allocation through `&self` is
115/// unsynchronized, and the `Cell` bump offset makes that a compile error rather
116/// than a data race. A job system wants one arena per worker anyway.
117pub struct FrameArena {
118    /// Owns the backing allocation and nothing else. Deliberately never read
119    /// after `with_capacity` — see fact 2 above.
120    _storage: Vec<Block>,
121    /// Start of the block. Every allocation derives from this pointer.
122    base: NonNull<u8>,
123    capacity: usize,
124    /// Bump offset in bytes from `base`.
125    offset: Cell<usize>,
126    peak: Cell<usize>,
127}
128
129// `clippy::mut_from_ref` is exactly what this type does on purpose: allocation
130// takes `&self` and returns `&mut [T]`. The soundness argument is the Aliasing
131// section on `FrameArena` — disjoint regions, an immovable block, and `reset`
132// gated behind `&mut self`. `bumpalo` allows the same lint for the same reason.
133#[allow(clippy::mut_from_ref)]
134impl FrameArena {
135    /// Creates an arena with at least `capacity` bytes, rounded up to a
136    /// multiple of the 64-byte block size.
137    #[must_use]
138    pub fn with_capacity(capacity: usize) -> Self {
139        let blocks = capacity.div_ceil(BLOCK_ALIGN);
140        let mut storage = vec![Block([0; BLOCK_ALIGN]); blocks];
141        // The one and only reborrow of the block. Its provenance covers the
142        // whole allocation, and every later allocation is derived from it.
143        let base =
144            NonNull::new(storage.as_mut_ptr().cast::<u8>()).expect("Vec::as_mut_ptr is never null");
145        Self {
146            _storage: storage,
147            base,
148            capacity: blocks * BLOCK_ALIGN,
149            offset: Cell::new(0),
150            peak: Cell::new(0),
151        }
152    }
153
154    /// Bytes handed out since the last [`FrameArena::reset`].
155    #[inline]
156    #[must_use]
157    pub fn used(&self) -> usize {
158        self.offset.get()
159    }
160
161    /// Total bytes available per frame.
162    #[inline]
163    #[must_use]
164    pub const fn capacity(&self) -> usize {
165        self.capacity
166    }
167
168    /// Bytes still available this frame (before alignment padding).
169    #[inline]
170    #[must_use]
171    pub fn remaining(&self) -> usize {
172        self.capacity - self.offset.get()
173    }
174
175    /// Used / capacity / peak.
176    #[inline]
177    #[must_use]
178    pub fn stats(&self) -> ArenaStats {
179        ArenaStats {
180            used: self.offset.get(),
181            capacity: self.capacity,
182            peak: self.peak.get(),
183        }
184    }
185
186    /// Frees everything allocated this frame. O(1); the memory is not touched,
187    /// so the next frame reads whatever the last one wrote until it
188    /// initializes its own data.
189    ///
190    /// Takes `&mut self`: this is the frame boundary, and the exclusive borrow
191    /// is what proves every slice from the last frame is already gone.
192    ///
193    /// The high-water mark survives — that is the point of tracking it.
194    #[inline]
195    pub fn reset(&mut self) {
196        self.offset.set(0);
197    }
198
199    /// Forgets the high-water mark (e.g. when entering a new level and the old
200    /// peak stops being informative).
201    #[inline]
202    pub fn reset_peak(&mut self) {
203        self.peak.set(self.offset.get());
204    }
205
206    /// Allocates `len` copies of `value`.
207    ///
208    /// # Panics
209    ///
210    /// If the arena is exhausted — see the growth policy in the type docs — or
211    /// if `T` needs more than 64-byte alignment.
212    #[inline]
213    pub fn alloc_slice<T: Copy>(&self, len: usize, value: T) -> &mut [T] {
214        let (capacity, free) = (self.capacity, self.remaining());
215        match self.try_alloc_slice(len, value) {
216            Some(slice) => slice,
217            None => exhausted::<T>(len, free, capacity),
218        }
219    }
220
221    /// Allocates `len` copies of `value`, or `None` if the arena is exhausted.
222    ///
223    /// # Panics
224    ///
225    /// If `T` needs more than 64-byte alignment — that is a programming error,
226    /// not a budget overrun, so it is not reported through the `Option`.
227    pub fn try_alloc_slice<T: Copy>(&self, len: usize, value: T) -> Option<&mut [T]> {
228        let ptr = self.reserve::<T>(len)?;
229        // SAFETY: `reserve` guarantees `len` correctly aligned, exclusively
230        // owned, writable elements at `ptr`. Every one of them is initialized
231        // here, before the slice exists, so no reference to uninitialized
232        // memory is ever created. `T: Copy` means there is nothing to drop.
233        unsafe {
234            for index in 0..len {
235                ptr.add(index).write(value);
236            }
237            Some(slice::from_raw_parts_mut(ptr, len))
238        }
239    }
240
241    /// Copies `src` into the arena.
242    ///
243    /// # Panics
244    ///
245    /// As [`FrameArena::alloc_slice`].
246    #[inline]
247    pub fn alloc_slice_copy<T: Copy>(&self, src: &[T]) -> &mut [T] {
248        let (capacity, free) = (self.capacity, self.remaining());
249        match self.try_alloc_slice_copy(src) {
250            Some(slice) => slice,
251            None => exhausted::<T>(src.len(), free, capacity),
252        }
253    }
254
255    /// Copies `src` into the arena, or returns `None` if it is exhausted.
256    ///
257    /// # Panics
258    ///
259    /// As [`FrameArena::try_alloc_slice`].
260    pub fn try_alloc_slice_copy<T: Copy>(&self, src: &[T]) -> Option<&mut [T]> {
261        let ptr = self.reserve::<T>(src.len())?;
262        // SAFETY: as `try_alloc_slice`, with a memcpy doing the initializing.
263        // The destination cannot overlap `src`: `reserve` only ever returns a
264        // range no live reference already covers, and `src` is live here.
265        unsafe {
266            ptr.copy_from_nonoverlapping(src.as_ptr(), src.len());
267            Some(slice::from_raw_parts_mut(ptr, src.len()))
268        }
269    }
270
271    /// Allocates a single value.
272    ///
273    /// # Panics
274    ///
275    /// As [`FrameArena::alloc_slice`].
276    #[inline]
277    pub fn alloc<T: Copy>(&self, value: T) -> &mut T {
278        &mut self.alloc_slice(1, value)[0]
279    }
280
281    /// Bumps the offset and returns a pointer to `len` **uninitialized**
282    /// elements, or `None` if that does not fit.
283    ///
284    /// Deliberately returns a raw pointer rather than `&mut [T]`: the memory is
285    /// uninitialized at this point, and a reference to uninitialized memory is
286    /// not something to hand around even privately.
287    fn reserve<T: Copy>(&self, len: usize) -> Option<*mut T> {
288        let align = mem::align_of::<T>();
289        assert!(
290            align <= BLOCK_ALIGN,
291            "FrameArena supports alignments up to {BLOCK_ALIGN}, {} needs {align}",
292            core::any::type_name::<T>(),
293        );
294        let size = mem::size_of::<T>().checked_mul(len)?;
295        let start = self.offset.get().next_multiple_of(align);
296        let end = start.checked_add(size)?;
297        if end > self.capacity {
298            return None;
299        }
300        self.offset.set(end);
301        self.peak.set(self.peak.get().max(end));
302
303        if size == 0 {
304            // Zero-sized (or empty) allocations never touch the block; a
305            // dangling-but-aligned pointer is the correct answer for them.
306            return Some(NonNull::<T>::dangling().as_ptr());
307        }
308
309        // SAFETY: `start .. start + size` lies inside the block — `end` was
310        // checked against `capacity`, which is exactly the block's byte length,
311        // and `base`'s provenance covers the whole block. `start` is a multiple
312        // of `align_of::<T>()` and the block's base is 64-byte aligned (`Block`
313        // is `repr(align(64))`, and `BLOCK_ALIGN` is asserted above to be the
314        // maximum supported alignment), so the result is aligned for `T`.
315        //
316        // The region is unaliased even though several allocations can be live
317        // at once: the bump offset only advances between resets, so this range
318        // has never been handed out before, and it cannot be handed out again
319        // until `reset` — which needs `&mut self`, i.e. proof that every slice
320        // derived from this arena is already gone. Deriving from `base` rather
321        // than from `_storage` is what keeps the earlier slices valid; see the
322        // Aliasing section on `FrameArena`.
323        Some(unsafe { self.base.as_ptr().add(start).cast::<T>() })
324    }
325}
326
327/// Shared panic path for the infallible allocators — cold, and out of line so
328/// the happy path stays small.
329#[cold]
330#[inline(never)]
331fn exhausted<T>(len: usize, free: usize, capacity: usize) -> ! {
332    panic!(
333        "FrameArena exhausted: wanted {} bytes for [{}; {len}], {free} of {capacity} free",
334        mem::size_of::<T>().saturating_mul(len),
335        core::any::type_name::<T>(),
336    )
337}
338
339impl core::fmt::Debug for FrameArena {
340    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
341        f.debug_struct("FrameArena")
342            .field("used", &self.offset.get())
343            .field("capacity", &self.capacity)
344            .field("peak", &self.peak.get())
345            .finish()
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn capacity_rounds_up_to_a_whole_block() {
355        let arena = FrameArena::with_capacity(1);
356        assert_eq!(arena.capacity(), BLOCK_ALIGN);
357        let arena = FrameArena::with_capacity(BLOCK_ALIGN + 1);
358        assert_eq!(arena.capacity(), BLOCK_ALIGN * 2);
359    }
360
361    #[test]
362    fn allocations_are_aligned_for_their_type() {
363        #[derive(Clone, Copy, Debug, PartialEq)]
364        #[repr(align(32))]
365        struct Wide(u64);
366
367        let arena = FrameArena::with_capacity(4096);
368        // Deliberately misalign the bump pointer first.
369        let bytes = arena.alloc_slice(3, 0u8);
370        assert_eq!(bytes.len(), 3);
371        let wide = arena.alloc_slice(2, Wide(7));
372        assert_eq!(wide.as_ptr() as usize % 32, 0);
373        assert_eq!(wide, [Wide(7), Wide(7)]);
374
375        let floats = arena.alloc_slice(5, 1.5f64);
376        assert_eq!(floats.as_ptr() as usize % mem::align_of::<f64>(), 0);
377        assert!(floats.iter().all(|f| *f == 1.5));
378    }
379
380    #[test]
381    fn allocations_do_not_overlap() {
382        let arena = FrameArena::with_capacity(4096);
383        let first = arena.alloc_slice(8, 1u32).as_ptr() as usize;
384        let second = arena.alloc_slice(8, 2u32);
385        assert!(second.iter().all(|v| *v == 2));
386        assert!(second.as_ptr() as usize >= first + 8 * 4);
387    }
388
389    /// The case the `&mut self` API could not express: several allocations
390    /// live at once, written through independently, none corrupting another.
391    /// This is the real frame shape — instance list, draw arguments, indices —
392    /// so it is pinned rather than left to Miri to notice by accident.
393    #[test]
394    fn many_slices_stay_live_and_independent() {
395        let mut arena = FrameArena::with_capacity(4096);
396
397        let instances = arena.alloc_slice(4, [0.0f32; 4]);
398        let draw_args = arena.alloc_slice(3, 0u32);
399        let indices = arena.alloc_slice_copy(&[10u16, 11, 12, 13, 14]);
400        let flag = arena.alloc(false);
401        // A differently-aligned type wedged between them, to make sure the
402        // padding does not let two allocations share a byte.
403        let wide = arena.alloc_slice(2, 0u64);
404
405        // Interleave the writes, so a stale or overlapping pointer corrupts a
406        // neighbour rather than merely being overwritten in order.
407        for round in 0..8u32 {
408            for (index, instance) in instances.iter_mut().enumerate() {
409                instance[index % 4] = (round * 10 + index as u32) as f32;
410            }
411            for (index, arg) in draw_args.iter_mut().enumerate() {
412                *arg = round * 100 + index as u32;
413            }
414            for (index, wide) in wide.iter_mut().enumerate() {
415                *wide = u64::from(round) << 32 | index as u64;
416            }
417            *flag = round.is_multiple_of(2);
418        }
419
420        assert_eq!(instances[0][0], 70.0);
421        assert_eq!(instances[3][3], 73.0);
422        assert_eq!(draw_args, [700, 701, 702]);
423        assert_eq!(
424            indices,
425            [10, 11, 12, 13, 14],
426            "untouched slice must be intact"
427        );
428        assert_eq!(wide, [7u64 << 32, 7u64 << 32 | 1]);
429        assert!(!*flag);
430
431        // Every region is disjoint from every other.
432        let spans = [
433            (instances.as_ptr() as usize, size_of_val(&*instances)),
434            (draw_args.as_ptr() as usize, size_of_val(&*draw_args)),
435            (indices.as_ptr() as usize, size_of_val(&*indices)),
436            (wide.as_ptr() as usize, size_of_val(&*wide)),
437            (std::ptr::from_ref(&*flag) as usize, size_of_val(&*flag)),
438        ];
439        for (i, (start, len)) in spans.iter().enumerate() {
440            for (other_start, other_len) in spans.iter().skip(i + 1) {
441                assert!(
442                    start + len <= *other_start || other_start + other_len <= *start,
443                    "allocations overlap: {start}+{len} vs {other_start}+{other_len}"
444                );
445            }
446        }
447
448        // Every borrow above ends at its last use, so `reset` — which needs
449        // `&mut self` — compiles here and nowhere earlier. The borrow checker
450        // is the frame boundary.
451        arena.reset();
452        assert_eq!(arena.used(), 0);
453    }
454
455    /// Allocation needs no `mut` binding at all: `&FrameArena` is enough, which
456    /// is what lets a frame arena be shared by reference across systems.
457    #[test]
458    fn allocation_works_through_a_shared_reference() {
459        let arena = FrameArena::with_capacity(256);
460        fn fill(arena: &FrameArena, value: u8) -> &mut [u8] {
461            arena.alloc_slice(8, value)
462        }
463        let a = fill(&arena, 1);
464        let b = fill(&arena, 2);
465        a[0] = 9;
466        assert_eq!(a, [9, 1, 1, 1, 1, 1, 1, 1]);
467        assert_eq!(b, [2; 8]);
468        assert_eq!(arena.used(), 16);
469    }
470
471    #[test]
472    fn slices_can_be_copied_in() {
473        let arena = FrameArena::with_capacity(256);
474        let src = [1.0f32, 2.0, 3.0, 4.0];
475        let copied = arena.alloc_slice_copy(&src);
476        assert_eq!(copied, src);
477        copied[0] = 9.0;
478        assert_eq!(src[0], 1.0, "the arena must hold a copy, not a view");
479        assert_eq!(arena.used(), 16);
480        assert!(arena.try_alloc_slice_copy(&[0u8; 512]).is_none());
481    }
482
483    #[test]
484    fn an_arena_allocation_hands_back_storage_the_caller_can_write_through() {
485        let arena = FrameArena::with_capacity(256);
486        let value = arena.alloc([1u16, 2, 3]);
487        value[1] = 9;
488        assert_eq!(*value, [1, 9, 3]);
489    }
490
491    #[test]
492    fn zero_sized_and_empty_allocations_cost_nothing() {
493        let arena = FrameArena::with_capacity(128);
494        let empty = arena.alloc_slice(0, 1u64);
495        assert!(empty.is_empty());
496        let units = arena.alloc_slice(1000, ());
497        assert_eq!(units.len(), 1000);
498        assert_eq!(arena.used(), 0);
499    }
500
501    #[test]
502    fn reset_reuses_the_block_and_keeps_the_peak() {
503        let mut arena = FrameArena::with_capacity(1024);
504        let first = arena.alloc_slice(64, 0u8).as_ptr() as usize;
505        assert_eq!(arena.used(), 64);
506        arena.reset();
507        assert_eq!(arena.used(), 0);
508        assert_eq!(arena.stats().peak, 64);
509
510        let second = arena.alloc_slice(16, 0u8).as_ptr() as usize;
511        assert_eq!(second, first, "reset must hand back the same memory");
512        assert_eq!(
513            arena.stats(),
514            ArenaStats {
515                used: 16,
516                capacity: 1024,
517                peak: 64
518            }
519        );
520
521        arena.reset_peak();
522        assert_eq!(arena.stats().peak, 16);
523    }
524
525    #[test]
526    fn peak_tracks_the_worst_frame() {
527        let mut arena = FrameArena::with_capacity(1024);
528        for len in [10usize, 700, 3, 200] {
529            arena.reset();
530            let _ = arena.alloc_slice(len, 0u8);
531        }
532        assert_eq!(arena.stats().peak, 700);
533        assert!((arena.stats().peak_fraction() - 700.0 / 1024.0).abs() < 1e-6);
534    }
535
536    #[test]
537    fn exhaustion_is_reported_not_grown_into() {
538        let arena = FrameArena::with_capacity(128);
539        assert!(arena.try_alloc_slice(100, 0u8).is_some());
540        assert_eq!(arena.remaining(), 28);
541        assert!(arena.try_alloc_slice(29, 0u8).is_none());
542        assert_eq!(arena.capacity(), 128, "capacity must not have grown");
543        // The failed attempt must not have moved the bump pointer.
544        assert_eq!(arena.used(), 100);
545        assert!(arena.try_alloc_slice(28, 0u8).is_some());
546    }
547
548    #[test]
549    #[should_panic(expected = "FrameArena exhausted")]
550    fn alloc_panics_when_exhausted() {
551        let arena = FrameArena::with_capacity(64);
552        let _ = arena.alloc_slice(65, 0u8);
553    }
554
555    #[test]
556    fn a_fresh_arena_reports_default_stats_and_a_zero_peak_fraction() {
557        let arena = FrameArena::with_capacity(0);
558        assert_eq!(arena.stats(), ArenaStats::default());
559        assert_eq!(arena.stats().peak_fraction(), 0.0);
560        assert!(format!("{arena:?}").contains("FrameArena"));
561    }
562}