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(®ion.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}