Skip to main content

ipfrs_tensorlogic/
tensor_arena.rs

1//! Arena allocator for inference pipeline tensor memory management.
2//!
3//! Inference pipelines allocate many small tensors. An arena allocator avoids
4//! per-tensor heap allocation by bump-allocating from pre-allocated slabs.
5//! This module provides [`TensorArena`], [`ArenaRegion`], [`ArenaSlice`],
6//! [`ArenaStats`], and [`ArenaError`].
7//!
8//! # Design
9//!
10//! The arena is organized as a list of fixed-size [`ArenaRegion`] slabs.
11//! Each region is a `Vec<u8>` with a bump pointer (`offset`). Allocations
12//! are always 8-byte aligned. When a region is full a new one is created.
13//!
14//! Lifetimes are avoided by representing allocated memory as `(start, end)`
15//! byte ranges within a region's slab, wrapped in [`ArenaSlice`].
16//!
17//! # Example
18//!
19//! ```
20//! use ipfrs_tensorlogic::tensor_arena::{TensorArena, ArenaError};
21//!
22//! let mut arena = TensorArena::new(1024 * 1024); // 1 MB regions
23//!
24//! // Allocate space for 4 f32 values (16 bytes)
25//! let slice = arena.allocate(4 * 4);
26//!
27//! // Write and read back
28//! slice.write_f32(&mut arena, &[1.0, 2.0, 3.0, 4.0]).expect("example: should succeed in docs");
29//! let values = slice.read_f32(&arena);
30//! assert_eq!(values, &[1.0f32, 2.0, 3.0, 4.0]);
31//!
32//! // Reset for reuse (no deallocation)
33//! arena.reset_all();
34//! ```
35
36use bytemuck::{cast_slice, cast_slice_mut};
37use thiserror::Error;
38
39// ---------------------------------------------------------------------------
40// Constants
41// ---------------------------------------------------------------------------
42
43/// Fixed alignment for all arena allocations (8 bytes).
44const ARENA_ALIGN: usize = 8;
45
46// ---------------------------------------------------------------------------
47// ArenaError
48// ---------------------------------------------------------------------------
49
50/// Errors that can occur during arena operations.
51#[derive(Debug, Error, PartialEq, Eq)]
52pub enum ArenaError {
53    /// The number of bytes provided does not match the slice's expected size.
54    #[error("size mismatch: expected {expected} bytes, got {got} bytes")]
55    SizeMismatch { expected: usize, got: usize },
56
57    /// The region index stored in an [`ArenaSlice`] does not exist.
58    #[error("region index {0} not found in arena")]
59    RegionNotFound(usize),
60}
61
62// ---------------------------------------------------------------------------
63// ArenaStats
64// ---------------------------------------------------------------------------
65
66/// Cumulative statistics for a [`TensorArena`].
67#[derive(Debug, Default, Clone, PartialEq, Eq)]
68pub struct ArenaStats {
69    /// Total number of successful `allocate` calls.
70    pub total_allocations: u64,
71    /// Total bytes handed out across all allocations.
72    pub total_bytes_allocated: u64,
73    /// Number of times [`TensorArena::reset_all`] has been called.
74    pub total_resets: u64,
75    /// Number of [`ArenaRegion`] slabs that have been created.
76    pub regions_created: u64,
77}
78
79// ---------------------------------------------------------------------------
80// ArenaRegion
81// ---------------------------------------------------------------------------
82
83/// A contiguous slab of memory with a bump pointer.
84///
85/// Memory is never freed individually. Call [`reset`](ArenaRegion::reset) to
86/// reclaim the entire region at once.
87#[derive(Debug)]
88pub struct ArenaRegion {
89    /// Backing byte storage.
90    slab: Vec<u8>,
91    /// Current bump pointer — index of the first unused byte.
92    offset: usize,
93    /// Total capacity in bytes.
94    capacity: usize,
95}
96
97impl ArenaRegion {
98    /// Create a new region with `capacity` bytes pre-allocated.
99    pub fn new(capacity: usize) -> Self {
100        Self {
101            slab: vec![0u8; capacity],
102            offset: 0,
103            capacity,
104        }
105    }
106
107    /// Bump-allocate `size` bytes aligned to `ARENA_ALIGN`.
108    ///
109    /// Returns `Some((start, end))` where the range `[start, end)` inside
110    /// `slab` is exclusively owned by the caller until `reset` is called.
111    /// Returns `None` if there is insufficient space.
112    pub fn allocate(&mut self, size: usize) -> Option<(usize, usize)> {
113        if size == 0 {
114            return Some((self.offset, self.offset));
115        }
116
117        // Align the current offset up to ARENA_ALIGN.
118        let aligned_start = align_up(self.offset, ARENA_ALIGN);
119        let end = aligned_start.checked_add(size)?;
120
121        if end > self.capacity {
122            return None;
123        }
124
125        self.offset = end;
126        Some((aligned_start, end))
127    }
128
129    /// Bytes still available for allocation (before alignment waste).
130    pub fn remaining(&self) -> usize {
131        self.capacity.saturating_sub(self.offset)
132    }
133
134    /// Fraction of capacity that has been allocated (`[0.0, 1.0]`).
135    ///
136    /// Returns `0.0` for a zero-capacity region to avoid division by zero.
137    pub fn utilization(&self) -> f64 {
138        if self.capacity == 0 {
139            return 0.0;
140        }
141        self.offset as f64 / self.capacity as f64
142    }
143
144    /// Reset the bump pointer to zero, logically freeing all allocations.
145    ///
146    /// This does **not** release the backing `Vec` memory.
147    pub fn reset(&mut self) {
148        self.offset = 0;
149    }
150
151    /// Immutable view of the backing slab.
152    #[inline]
153    pub fn slab(&self) -> &[u8] {
154        &self.slab
155    }
156
157    /// Mutable view of the backing slab.
158    #[inline]
159    pub fn slab_mut(&mut self) -> &mut [u8] {
160        &mut self.slab
161    }
162
163    /// Total capacity in bytes.
164    #[inline]
165    pub fn capacity(&self) -> usize {
166        self.capacity
167    }
168
169    /// Current bump-pointer offset.
170    #[inline]
171    pub fn offset(&self) -> usize {
172        self.offset
173    }
174}
175
176// ---------------------------------------------------------------------------
177// ArenaSlice
178// ---------------------------------------------------------------------------
179
180/// A handle to a contiguous byte range inside a [`TensorArena`] region.
181///
182/// Validity is not enforced by the type system — the caller must not use an
183/// `ArenaSlice` after calling [`TensorArena::reset_all`] if new allocations
184/// have been made that may overlap.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub struct ArenaSlice {
187    /// Index of the owning region in [`TensorArena::regions`].
188    pub region_index: usize,
189    /// Start byte offset (inclusive) within the region slab.
190    pub start: usize,
191    /// End byte offset (exclusive) within the region slab.
192    pub end: usize,
193}
194
195impl ArenaSlice {
196    /// Number of bytes in this slice.
197    #[inline]
198    pub fn len(&self) -> usize {
199        self.end - self.start
200    }
201
202    /// Returns `true` if the slice covers zero bytes.
203    #[inline]
204    pub fn is_empty(&self) -> bool {
205        self.start == self.end
206    }
207
208    /// Write `values` (as raw bytes) into this slice's range in the arena.
209    ///
210    /// # Errors
211    ///
212    /// - [`ArenaError::RegionNotFound`] — if `self.region_index` is out of
213    ///   bounds.
214    /// - [`ArenaError::SizeMismatch`] — if `values.len() * 4 != self.len()`.
215    pub fn write_f32(&self, arena: &mut TensorArena, values: &[f32]) -> Result<(), ArenaError> {
216        let region = arena
217            .regions
218            .get_mut(self.region_index)
219            .ok_or(ArenaError::RegionNotFound(self.region_index))?;
220
221        let expected = self.len();
222        let got = std::mem::size_of_val(values);
223
224        if expected != got {
225            return Err(ArenaError::SizeMismatch { expected, got });
226        }
227
228        let dst: &mut [f32] = cast_slice_mut(&mut region.slab[self.start..self.end]);
229        dst.copy_from_slice(values);
230        Ok(())
231    }
232
233    /// Read a `&[f32]` from this slice's range in the arena.
234    ///
235    /// # Panics
236    ///
237    /// Panics if `self.region_index` is out of bounds or if the byte range is
238    /// not properly aligned / sized for `f32`.  In production code the caller
239    /// should ensure the slice was created by [`TensorArena::allocate`] with a
240    /// size that is a multiple of 4.
241    pub fn read_f32<'a>(&self, arena: &'a TensorArena) -> &'a [f32] {
242        let region = &arena.regions[self.region_index];
243        cast_slice(&region.slab[self.start..self.end])
244    }
245}
246
247// ---------------------------------------------------------------------------
248// TensorArena
249// ---------------------------------------------------------------------------
250
251/// Bump-allocating arena for inference-pipeline tensors.
252///
253/// Allocations are O(1) — a bump pointer is incremented and, when a region is
254/// exhausted, a new fixed-size region is appended.  Freeing all tensors is
255/// O(regions) via [`reset_all`](TensorArena::reset_all).
256pub struct TensorArena {
257    /// All memory regions, ordered by creation time.
258    pub regions: Vec<ArenaRegion>,
259    /// Default size (bytes) for each new region.
260    pub region_size: usize,
261    /// Cumulative statistics.
262    pub stats: ArenaStats,
263}
264
265impl TensorArena {
266    /// Construct a new arena whose regions will be `region_size` bytes each.
267    pub fn new(region_size: usize) -> Self {
268        let mut arena = Self {
269            regions: Vec::new(),
270            region_size,
271            stats: ArenaStats::default(),
272        };
273        // Pre-allocate the first region eagerly so that the first allocation
274        // does not trigger a regions_created bump that is surprising to callers
275        // that inspect stats before any allocation.  The region is included in
276        // regions_created.
277        arena.push_region();
278        arena
279    }
280
281    /// Allocate `size` bytes from the arena and return an [`ArenaSlice`].
282    ///
283    /// If the current (last) region cannot satisfy the request a new region is
284    /// created.  `size` is always rounded up to the next multiple of
285    /// `ARENA_ALIGN` internally.
286    pub fn allocate(&mut self, size: usize) -> ArenaSlice {
287        let size = align_up(size, ARENA_ALIGN);
288
289        // Try the current last region first.
290        let slice = self.try_allocate_in_last(size);
291
292        let slice = if let Some(s) = slice {
293            s
294        } else {
295            // Need a new region — make it large enough even for oversized
296            // allocations.
297            let region_size = self.region_size.max(size);
298            // Temporarily store region_size; push_region_with_size uses it.
299            let old_region_size = self.region_size;
300            self.region_size = region_size;
301            self.push_region();
302            self.region_size = old_region_size;
303
304            self.try_allocate_in_last(size)
305                .expect("freshly created region must accommodate the allocation")
306        };
307
308        self.stats.total_allocations += 1;
309        self.stats.total_bytes_allocated += size as u64;
310        slice
311    }
312
313    /// Reset every region's bump pointer, allowing all memory to be reused.
314    ///
315    /// Outstanding [`ArenaSlice`] handles become stale after this call.
316    pub fn reset_all(&mut self) {
317        for region in &mut self.regions {
318            region.reset();
319        }
320        self.stats.total_resets += 1;
321    }
322
323    /// Number of regions (slabs) currently held.
324    pub fn region_count(&self) -> usize {
325        self.regions.len()
326    }
327
328    /// Total capacity in bytes across all regions.
329    pub fn total_capacity(&self) -> u64 {
330        self.regions.iter().map(|r| r.capacity() as u64).sum()
331    }
332
333    /// Total bytes that have been bump-allocated (not yet reset).
334    pub fn total_used(&self) -> u64 {
335        self.regions.iter().map(|r| r.offset() as u64).sum()
336    }
337
338    /// Overall utilization as `total_used / total_capacity`.
339    ///
340    /// Returns `0.0` if there are no regions.
341    pub fn utilization(&self) -> f64 {
342        let cap = self.total_capacity();
343        if cap == 0 {
344            return 0.0;
345        }
346        self.total_used() as f64 / cap as f64
347    }
348
349    // ------------------------------------------------------------------
350    // Private helpers
351    // ------------------------------------------------------------------
352
353    /// Try to allocate `size` bytes from the last region.
354    fn try_allocate_in_last(&mut self, size: usize) -> Option<ArenaSlice> {
355        let idx = self.regions.len().checked_sub(1)?;
356        let region = &mut self.regions[idx];
357        let (start, end) = region.allocate(size)?;
358        Some(ArenaSlice {
359            region_index: idx,
360            start,
361            end,
362        })
363    }
364
365    /// Append a new region of `self.region_size` bytes.
366    fn push_region(&mut self) {
367        self.regions.push(ArenaRegion::new(self.region_size));
368        self.stats.regions_created += 1;
369    }
370}
371
372// ---------------------------------------------------------------------------
373// Utility
374// ---------------------------------------------------------------------------
375
376/// Round `n` up to the nearest multiple of `align` (which must be a power of 2).
377#[inline]
378fn align_up(n: usize, align: usize) -> usize {
379    debug_assert!(align.is_power_of_two());
380    (n + align - 1) & !(align - 1)
381}
382
383// ---------------------------------------------------------------------------
384// Tests
385// ---------------------------------------------------------------------------
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    // -----------------------------------------------------------------------
392    // ArenaRegion tests
393    // -----------------------------------------------------------------------
394
395    /// Test 1: allocate returns the correct (start, end) range.
396    #[test]
397    fn region_allocate_returns_correct_range() {
398        let mut region = ArenaRegion::new(64);
399        let result = region.allocate(16);
400        assert_eq!(result, Some((0, 16)));
401    }
402
403    /// Test 2: multiple allocations are sequential and non-overlapping.
404    #[test]
405    fn region_multiple_allocations_are_sequential() {
406        let mut region = ArenaRegion::new(128);
407        let r1 = region.allocate(16).expect("test: should succeed");
408        let r2 = region.allocate(16).expect("test: should succeed");
409        // Second allocation must start where first ended.
410        assert_eq!(r2.0, r1.1);
411        assert_eq!(r1, (0, 16));
412        assert_eq!(r2, (16, 32));
413    }
414
415    /// Test 3: remaining decreases after allocation.
416    #[test]
417    fn region_remaining_decreases_after_allocation() {
418        let mut region = ArenaRegion::new(64);
419        let before = region.remaining();
420        region.allocate(16).expect("test: should succeed");
421        let after = region.remaining();
422        assert!(after < before, "remaining should decrease after allocation");
423        assert_eq!(after, before - 16);
424    }
425
426    /// Test 4: allocate returns None when space is insufficient.
427    #[test]
428    fn region_allocate_returns_none_when_full() {
429        let mut region = ArenaRegion::new(8);
430        assert!(region.allocate(16).is_none());
431    }
432
433    /// Test 5: reset restores remaining to capacity.
434    #[test]
435    fn region_reset_restores_remaining() {
436        let mut region = ArenaRegion::new(64);
437        region.allocate(32).expect("test: should succeed");
438        assert_eq!(region.remaining(), 32);
439        region.reset();
440        assert_eq!(region.remaining(), 64);
441    }
442
443    /// Test 6: utilization is 0 on fresh region.
444    #[test]
445    fn region_utilization_is_zero_initially() {
446        let region = ArenaRegion::new(1024);
447        assert_eq!(region.utilization(), 0.0);
448    }
449
450    /// Test 7: utilization reaches ~1.0 when almost full.
451    #[test]
452    fn region_utilization_approaches_one_when_full() {
453        let mut region = ArenaRegion::new(64);
454        region.allocate(64).expect("test: should succeed");
455        let u = region.utilization();
456        assert!((u - 1.0).abs() < 1e-9, "expected ~1.0, got {u}");
457    }
458
459    // -----------------------------------------------------------------------
460    // TensorArena tests
461    // -----------------------------------------------------------------------
462
463    /// Test 8: arena starts with one region.
464    #[test]
465    fn arena_starts_with_one_region() {
466        let arena = TensorArena::new(1024);
467        assert_eq!(arena.region_count(), 1);
468    }
469
470    /// Test 9: allocate creates a new region when the first is full.
471    #[test]
472    fn arena_allocate_creates_new_region_when_needed() {
473        let region_size = 32;
474        let mut arena = TensorArena::new(region_size);
475        // Fill the first region.
476        arena.allocate(32);
477        // Next allocation must spill into a second region.
478        let slice = arena.allocate(8);
479        assert_eq!(arena.region_count(), 2);
480        assert_eq!(slice.region_index, 1);
481    }
482
483    /// Test 10: write_f32 / read_f32 round-trip.
484    #[test]
485    fn arena_write_read_f32_round_trip() {
486        let mut arena = TensorArena::new(1024 * 1024);
487        let values: Vec<f32> = (0..8).map(|i| i as f32 * 0.5).collect();
488        let byte_len = values.len() * core::mem::size_of::<f32>();
489        let slice = arena.allocate(byte_len);
490        slice
491            .write_f32(&mut arena, &values)
492            .expect("test: should succeed");
493        let read_back = slice.read_f32(&arena);
494        assert_eq!(read_back, values.as_slice());
495    }
496
497    /// Test 11: reset_all allows memory to be reused.
498    #[test]
499    fn arena_reset_all_allows_reuse() {
500        let mut arena = TensorArena::new(64);
501        let s1 = arena.allocate(16);
502        arena.reset_all();
503        let s2 = arena.allocate(16);
504        // After reset the bump pointer starts over, so offsets should match.
505        assert_eq!(s1.start, s2.start);
506        assert_eq!(s1.end, s2.end);
507    }
508
509    /// Test 12: utilization calculation is correct.
510    #[test]
511    fn arena_utilization_calculation() {
512        let region_size = 1024;
513        let mut arena = TensorArena::new(region_size);
514        // Allocate exactly half the first region.
515        arena.allocate(512);
516        let u = arena.utilization();
517        assert!((u - 0.5).abs() < 0.01, "expected ~0.5 utilization, got {u}");
518    }
519
520    /// Test 13: stats accumulate correctly across multiple allocations.
521    #[test]
522    fn arena_stats_accumulate() {
523        let mut arena = TensorArena::new(1024 * 1024);
524        arena.allocate(16);
525        arena.allocate(32);
526        arena.allocate(64);
527        assert_eq!(arena.stats.total_allocations, 3);
528        // Bytes are aligned to 8 so: 16 + 32 + 64 = 112
529        assert_eq!(arena.stats.total_bytes_allocated, 112);
530    }
531
532    /// Test 14: large allocation triggers a new region sized for the request.
533    #[test]
534    fn arena_large_allocation_triggers_new_region() {
535        let region_size = 64;
536        let mut arena = TensorArena::new(region_size);
537        // This is larger than the default region_size.
538        let large = 4096;
539        let slice = arena.allocate(large);
540        // A new region must have been created to accommodate this.
541        // (The first region has region_size = 64 bytes, but 4096 > 64.)
542        let region = &arena.regions[slice.region_index];
543        assert!(region.capacity() >= large);
544    }
545
546    /// Test 15: write_f32 returns SizeMismatch error on wrong length.
547    #[test]
548    fn arena_write_f32_size_mismatch_error() {
549        let mut arena = TensorArena::new(1024);
550        let slice = arena.allocate(16); // 4 f32
551        let result = slice.write_f32(&mut arena, &[1.0, 2.0]); // only 2 f32
552        assert!(matches!(result, Err(ArenaError::SizeMismatch { .. })));
553    }
554
555    /// Test 16: multiple allocations in the same region are non-overlapping.
556    #[test]
557    fn arena_multiple_allocs_in_same_region_non_overlapping() {
558        let mut arena = TensorArena::new(1024 * 1024);
559        let a = arena.allocate(64);
560        let b = arena.allocate(64);
561        let c = arena.allocate(128);
562        // All in the first region.
563        assert_eq!(a.region_index, b.region_index);
564        assert_eq!(b.region_index, c.region_index);
565        // Non-overlapping: a ends where b starts.
566        assert_eq!(a.end, b.start);
567        assert_eq!(b.end, c.start);
568    }
569
570    /// Test 17: reset_all increments total_resets stat.
571    #[test]
572    fn arena_reset_all_increments_stats() {
573        let mut arena = TensorArena::new(1024);
574        arena.reset_all();
575        arena.reset_all();
576        assert_eq!(arena.stats.total_resets, 2);
577    }
578
579    /// Test 18: region_index in ArenaSlice is valid after multi-region spill.
580    #[test]
581    fn arena_slice_region_index_valid_after_spill() {
582        let region_size = 32;
583        let mut arena = TensorArena::new(region_size);
584        arena.allocate(32); // fill region 0
585        let s = arena.allocate(8); // spills to region 1
586                                   // The slice is 8 bytes (aligned), so write two f32 values.
587        s.write_f32(&mut arena, &[42.0, 7.0])
588            .expect("test: should succeed");
589        let vals = s.read_f32(&arena);
590        assert_eq!(vals, &[42.0f32, 7.0]);
591    }
592}