murk_arena/scratch.rs
1//! Per-tick scratch space for temporary propagator allocations.
2//!
3//! [`ScratchRegion`] is a bump allocator over a `Vec<f32>`. It is reset
4//! between propagator invocations by the tick engine, so each propagator
5//! sees a fresh, empty scratch region. The backing allocation is reused
6//! across ticks to avoid repeated heap allocation.
7
8/// Bump-allocated scratch space for temporary per-propagator data.
9///
10/// Propagators can request temporary f32 slices from scratch space for
11/// intermediate calculations (e.g. neighbourhood sums, gradient buffers).
12/// The scratch region is reset between propagator executions within a
13/// single tick — allocations do not persist across propagators or ticks.
14///
15/// # Example (conceptual)
16///
17/// ```ignore
18/// let slice = scratch.alloc(100)?;
19/// // use slice for intermediate computation
20/// // scratch.reset() called by engine before next propagator
21/// ```
22pub struct ScratchRegion {
23 /// Backing storage. Grows on demand, never shrinks during runtime.
24 data: Vec<f32>,
25 /// Current bump pointer (number of f32 elements allocated so far).
26 cursor: usize,
27}
28
29impl ScratchRegion {
30 /// Create a new scratch region with the given initial capacity (in f32 elements).
31 pub fn new(initial_capacity: usize) -> Self {
32 Self {
33 data: vec![0.0; initial_capacity],
34 cursor: 0,
35 }
36 }
37
38 /// Allocate `len` f32 elements from scratch space.
39 ///
40 /// Returns a zero-initialised mutable slice. Returns `None` if the
41 /// scratch region cannot grow to accommodate the request (this should
42 /// not happen in practice since `Vec` grows on demand; `None` is
43 /// returned only if the system is out of memory).
44 pub fn alloc(&mut self, len: usize) -> Option<&mut [f32]> {
45 let new_cursor = self.cursor.checked_add(len)?;
46 if new_cursor > self.data.len() {
47 // Grow to at least double or the required size, whichever is larger.
48 let new_cap = self
49 .data
50 .len()
51 .max(1024)
52 .max(new_cursor)
53 .checked_mul(2)
54 .unwrap_or(new_cursor);
55 self.data.resize(new_cap, 0.0);
56 }
57 let start = self.cursor;
58 self.cursor = new_cursor;
59 // Zero-init the newly allocated region (may have stale data from previous use).
60 let slice = &mut self.data[start..new_cursor];
61 slice.fill(0.0);
62 Some(slice)
63 }
64
65 /// Reset the scratch region for the next propagator.
66 ///
67 /// This does NOT deallocate or zero the backing storage — it simply
68 /// resets the bump pointer. The next `alloc` call will overwrite
69 /// stale data with zeroes before returning.
70 pub fn reset(&mut self) {
71 self.cursor = 0;
72 }
73
74 /// Number of f32 elements currently allocated.
75 pub fn used(&self) -> usize {
76 self.cursor
77 }
78
79 /// Total capacity of the backing storage in f32 elements.
80 pub fn capacity(&self) -> usize {
81 self.data.len()
82 }
83
84 /// Memory usage of the backing storage in bytes.
85 pub fn memory_bytes(&self) -> usize {
86 self.data.len() * std::mem::size_of::<f32>()
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn alloc_returns_zeroed_slice() {
96 let mut scratch = ScratchRegion::new(1024);
97 let s = scratch.alloc(10).unwrap();
98 assert_eq!(s.len(), 10);
99 assert!(s.iter().all(|&v| v == 0.0));
100 }
101
102 #[test]
103 fn sequential_allocs_dont_overlap() {
104 let mut scratch = ScratchRegion::new(1024);
105 let a = scratch.alloc(5).unwrap();
106 a[0] = 1.0;
107 a[4] = 5.0;
108 let a_ptr = a.as_ptr();
109
110 let b = scratch.alloc(3).unwrap();
111 b[0] = 10.0;
112 let b_ptr = b.as_ptr();
113
114 // Pointers should not overlap (b starts after a).
115 assert_ne!(a_ptr, b_ptr);
116 assert_eq!(scratch.used(), 8);
117 }
118
119 #[test]
120 fn reset_allows_reuse() {
121 let mut scratch = ScratchRegion::new(1024);
122 scratch.alloc(100).unwrap();
123 assert_eq!(scratch.used(), 100);
124
125 scratch.reset();
126 assert_eq!(scratch.used(), 0);
127
128 // Re-alloc after reset should return zeroed data.
129 let s = scratch.alloc(50).unwrap();
130 assert_eq!(s.len(), 50);
131 assert!(s.iter().all(|&v| v == 0.0));
132 }
133
134 #[test]
135 fn grows_beyond_initial_capacity() {
136 let mut scratch = ScratchRegion::new(10);
137 let s = scratch.alloc(100).unwrap();
138 assert_eq!(s.len(), 100);
139 assert!(scratch.capacity() >= 100);
140 }
141
142 #[test]
143 fn zero_alloc_is_valid() {
144 let mut scratch = ScratchRegion::new(1024);
145 let s = scratch.alloc(0).unwrap();
146 assert!(s.is_empty());
147 assert_eq!(scratch.used(), 0);
148 }
149
150 #[test]
151 fn memory_bytes_tracks_capacity() {
152 let scratch = ScratchRegion::new(1024);
153 assert_eq!(scratch.memory_bytes(), 1024 * 4);
154 }
155
156 #[test]
157 fn growth_overflow_falls_back_to_exact_fit() {
158 // When the doubling multiplication would overflow usize,
159 // the allocator should fall back to exact-fit (new_cursor).
160 // We can't actually allocate usize::MAX/2 f32s, but we can
161 // verify the capacity calculation doesn't panic.
162 let mut scratch = ScratchRegion::new(0);
163 // First alloc triggers growth from 0 → at least 1024 * 2.
164 let s = scratch.alloc(10).unwrap();
165 assert_eq!(s.len(), 10);
166 // The capacity should be at least 2048 (1024 min * 2).
167 assert!(scratch.capacity() >= 2048);
168 }
169}