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}