Skip to main content

oximedia_gpu/
memory_pool.rs

1//! GPU memory pool allocator.
2//!
3//! Provides block-based GPU memory allocation with alignment support and
4//! pool statistics tracking. Designed to reduce the overhead of frequent
5//! small allocations by sub-allocating from larger backing blocks.
6
7/// Alignment requirements for GPU memory blocks.
8#[allow(dead_code)]
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Alignment {
11    /// 4-byte alignment (default for most scalar types).
12    Bytes4 = 4,
13    /// 16-byte alignment (required for vec4 on many GPUs).
14    Bytes16 = 16,
15    /// 64-byte alignment (cache-line alignment).
16    Bytes64 = 64,
17    /// 256-byte alignment (required by some Vulkan/D3D12 rules).
18    Bytes256 = 256,
19    /// 4 KB alignment (page granularity).
20    Bytes4096 = 4096,
21}
22
23impl Alignment {
24    /// Value as `usize`.
25    #[allow(dead_code)]
26    #[must_use]
27    pub const fn as_usize(self) -> usize {
28        self as usize
29    }
30
31    /// Align `offset` up to the next multiple of this alignment.
32    #[allow(dead_code)]
33    #[must_use]
34    pub const fn align_up(self, offset: usize) -> usize {
35        let align = self as usize;
36        (offset + align - 1) & !(align - 1)
37    }
38}
39
40/// A single allocation handle returned to the caller.
41#[allow(dead_code)]
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct AllocationHandle {
44    /// Index of the backing block.
45    pub block_index: usize,
46    /// Byte offset within that block.
47    pub offset: usize,
48    /// Allocated size (may be larger than requested due to alignment).
49    pub size: usize,
50    /// Alignment used.
51    pub alignment: usize,
52    /// Opaque allocation id for deallocation.
53    pub id: u64,
54}
55
56/// Tracks free ranges inside a single backing block.
57#[allow(dead_code)]
58#[derive(Debug)]
59struct FreeRange {
60    offset: usize,
61    size: usize,
62}
63
64/// A single large backing allocation that sub-allocates smaller regions.
65#[allow(dead_code)]
66#[derive(Debug)]
67struct Block {
68    /// Total capacity of this block in bytes.
69    capacity: usize,
70    /// Byte ranges that are currently free.
71    free_ranges: Vec<FreeRange>,
72    /// Number of live sub-allocations.
73    live_count: usize,
74}
75
76impl Block {
77    fn new(capacity: usize) -> Self {
78        Self {
79            capacity,
80            free_ranges: vec![FreeRange {
81                offset: 0,
82                size: capacity,
83            }],
84            live_count: 0,
85        }
86    }
87
88    /// Try to allocate `size` bytes with `alignment`. Returns the aligned
89    /// offset on success.
90    fn try_alloc(&mut self, size: usize, alignment: usize) -> Option<usize> {
91        for range in &mut self.free_ranges {
92            let aligned_offset = (range.offset + alignment - 1) & !(alignment - 1);
93            let waste = aligned_offset - range.offset;
94            if range.size >= waste + size {
95                let result_offset = aligned_offset;
96                range.offset += waste + size;
97                range.size -= waste + size;
98                self.live_count += 1;
99                return Some(result_offset);
100            }
101        }
102        // Remove exhausted ranges.
103        self.free_ranges.retain(|r| r.size > 0);
104        None
105    }
106
107    /// Free a previously allocated region.
108    fn free(&mut self, offset: usize, size: usize) {
109        self.free_ranges.push(FreeRange { offset, size });
110        if self.live_count > 0 {
111            self.live_count -= 1;
112        }
113        // Coalesce adjacent free ranges (simple O(n²) version adequate here).
114        self.coalesce();
115    }
116
117    fn coalesce(&mut self) {
118        self.free_ranges.sort_by_key(|r| r.offset);
119        let mut i = 0;
120        while i + 1 < self.free_ranges.len() {
121            let end = self.free_ranges[i].offset + self.free_ranges[i].size;
122            if end >= self.free_ranges[i + 1].offset {
123                // Merge.
124                let merged_size = self.free_ranges[i + 1].offset + self.free_ranges[i + 1].size
125                    - self.free_ranges[i].offset;
126                self.free_ranges[i].size = merged_size;
127                self.free_ranges.remove(i + 1);
128            } else {
129                i += 1;
130            }
131        }
132    }
133
134    /// Bytes still free in this block (sum of all free ranges).
135    fn free_bytes(&self) -> usize {
136        self.free_ranges.iter().map(|r| r.size).sum()
137    }
138}
139
140/// Statistics for the memory pool.
141#[allow(dead_code)]
142#[derive(Debug, Clone, Default)]
143pub struct PoolStats {
144    /// Total bytes reserved across all backing blocks.
145    pub total_reserved: usize,
146    /// Total bytes currently allocated (live).
147    pub total_allocated: usize,
148    /// Number of backing blocks.
149    pub block_count: usize,
150    /// Total number of successful allocations.
151    pub alloc_count: u64,
152    /// Total number of deallocations.
153    pub free_count: u64,
154    /// Allocation failures due to fragmentation.
155    pub failures: u64,
156}
157
158impl PoolStats {
159    /// Bytes still free (reserved but not live-allocated).
160    #[allow(dead_code)]
161    #[must_use]
162    pub fn free_bytes(&self) -> usize {
163        self.total_reserved.saturating_sub(self.total_allocated)
164    }
165
166    /// Utilisation ratio (0.0 – 1.0).
167    #[allow(dead_code)]
168    #[must_use]
169    pub fn utilisation(&self) -> f64 {
170        if self.total_reserved == 0 {
171            0.0
172        } else {
173            self.total_allocated as f64 / self.total_reserved as f64
174        }
175    }
176}
177
178/// GPU memory pool allocator.
179#[allow(dead_code)]
180pub struct GpuMemoryPool {
181    /// Size of each new backing block in bytes.
182    block_size: usize,
183    /// All backing blocks.
184    blocks: Vec<Block>,
185    /// Statistics.
186    stats: PoolStats,
187    /// Monotonically increasing allocation id counter.
188    next_id: u64,
189}
190
191impl GpuMemoryPool {
192    /// Create a new pool.
193    ///
194    /// * `block_size` – size of each new backing block in bytes.
195    #[allow(dead_code)]
196    #[must_use]
197    pub fn new(block_size: usize) -> Self {
198        assert!(block_size > 0, "block_size must be > 0");
199        Self {
200            block_size,
201            blocks: Vec::new(),
202            stats: PoolStats::default(),
203            next_id: 0,
204        }
205    }
206
207    /// Allocate `size` bytes with the given `alignment`.
208    ///
209    /// Returns an [`AllocationHandle`] on success. If no existing block can
210    /// satisfy the request, a new backing block is created.
211    #[allow(dead_code)]
212    pub fn alloc(&mut self, size: usize, alignment: Alignment) -> Option<AllocationHandle> {
213        if size == 0 {
214            return None;
215        }
216        let align = alignment.as_usize();
217
218        // Try existing blocks first.
219        for (i, block) in self.blocks.iter_mut().enumerate() {
220            if let Some(offset) = block.try_alloc(size, align) {
221                let id = self.next_id;
222                self.next_id += 1;
223                self.stats.alloc_count += 1;
224                self.stats.total_allocated += size;
225                return Some(AllocationHandle {
226                    block_index: i,
227                    offset,
228                    size,
229                    alignment: align,
230                    id,
231                });
232            }
233        }
234
235        // Allocate a new block large enough.
236        let new_block_size = self.block_size.max(size + align);
237        let mut block = Block::new(new_block_size);
238        if let Some(offset) = block.try_alloc(size, align) {
239            self.stats.total_reserved += new_block_size;
240            self.stats.block_count += 1;
241            let block_index = self.blocks.len();
242            self.blocks.push(block);
243
244            let id = self.next_id;
245            self.next_id += 1;
246            self.stats.alloc_count += 1;
247            self.stats.total_allocated += size;
248            Some(AllocationHandle {
249                block_index,
250                offset,
251                size,
252                alignment: align,
253                id,
254            })
255        } else {
256            self.stats.failures += 1;
257            None
258        }
259    }
260
261    /// Free a previously allocated handle.
262    #[allow(dead_code)]
263    pub fn free(&mut self, handle: &AllocationHandle) {
264        if handle.block_index < self.blocks.len() {
265            self.blocks[handle.block_index].free(handle.offset, handle.size);
266            self.stats.total_allocated = self.stats.total_allocated.saturating_sub(handle.size);
267            self.stats.free_count += 1;
268        }
269    }
270
271    /// Current pool statistics.
272    #[allow(dead_code)]
273    #[must_use]
274    pub fn stats(&self) -> &PoolStats {
275        &self.stats
276    }
277
278    /// Total number of backing blocks.
279    #[allow(dead_code)]
280    #[must_use]
281    pub fn block_count(&self) -> usize {
282        self.blocks.len()
283    }
284
285    /// Total free bytes across all blocks.
286    #[allow(dead_code)]
287    #[must_use]
288    pub fn free_bytes(&self) -> usize {
289        self.blocks.iter().map(Block::free_bytes).sum()
290    }
291
292    /// Reset the pool – all backing blocks are cleared.
293    #[allow(dead_code)]
294    pub fn reset(&mut self) {
295        self.blocks.clear();
296        self.stats = PoolStats::default();
297        self.next_id = 0;
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Unit tests
303// ---------------------------------------------------------------------------
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_alignment_align_up() {
311        assert_eq!(Alignment::Bytes16.align_up(0), 0);
312        assert_eq!(Alignment::Bytes16.align_up(1), 16);
313        assert_eq!(Alignment::Bytes16.align_up(16), 16);
314        assert_eq!(Alignment::Bytes16.align_up(17), 32);
315    }
316
317    #[test]
318    fn test_alignment_as_usize() {
319        assert_eq!(Alignment::Bytes4.as_usize(), 4);
320        assert_eq!(Alignment::Bytes256.as_usize(), 256);
321    }
322
323    #[test]
324    fn test_simple_alloc() {
325        let mut pool = GpuMemoryPool::new(1024);
326        let handle = pool.alloc(64, Alignment::Bytes16);
327        assert!(handle.is_some());
328        let h = handle.unwrap();
329        assert_eq!(h.size, 64);
330        assert_eq!(h.offset % 16, 0);
331    }
332
333    #[test]
334    fn test_zero_size_alloc_returns_none() {
335        let mut pool = GpuMemoryPool::new(1024);
336        assert!(pool.alloc(0, Alignment::Bytes4).is_none());
337    }
338
339    #[test]
340    fn test_alloc_and_free_stats() {
341        let mut pool = GpuMemoryPool::new(1024);
342        let h = pool.alloc(100, Alignment::Bytes4).unwrap();
343        assert_eq!(pool.stats().total_allocated, 100);
344        pool.free(&h);
345        assert_eq!(pool.stats().total_allocated, 0);
346    }
347
348    #[test]
349    fn test_multiple_allocs_same_block() {
350        let mut pool = GpuMemoryPool::new(4096);
351        let h1 = pool.alloc(128, Alignment::Bytes64).unwrap();
352        let h2 = pool.alloc(128, Alignment::Bytes64).unwrap();
353        assert_eq!(h1.block_index, h2.block_index);
354        assert_eq!(pool.block_count(), 1);
355    }
356
357    #[test]
358    fn test_new_block_created_when_full() {
359        let mut pool = GpuMemoryPool::new(64);
360        // First alloc fills the initial block.
361        let _h1 = pool.alloc(64, Alignment::Bytes4).unwrap();
362        // Second alloc must create a new block.
363        let h2 = pool.alloc(64, Alignment::Bytes4).unwrap();
364        assert!(h2.block_index >= 1 || pool.block_count() == 2);
365    }
366
367    #[test]
368    fn test_pool_stats_utilisation() {
369        let mut pool = GpuMemoryPool::new(1000);
370        pool.alloc(500, Alignment::Bytes4);
371        let util = pool.stats().utilisation();
372        assert!(util > 0.0 && util <= 1.0);
373    }
374
375    #[test]
376    fn test_free_bytes_decreases_after_alloc() {
377        let mut pool = GpuMemoryPool::new(1024);
378        pool.alloc(256, Alignment::Bytes4);
379        assert!(pool.free_bytes() < 1024);
380    }
381
382    #[test]
383    fn test_reset_clears_all() {
384        let mut pool = GpuMemoryPool::new(512);
385        pool.alloc(100, Alignment::Bytes4);
386        pool.reset();
387        assert_eq!(pool.block_count(), 0);
388        assert_eq!(pool.stats().alloc_count, 0);
389    }
390
391    #[test]
392    fn test_alloc_id_increments() {
393        let mut pool = GpuMemoryPool::new(1024);
394        let h1 = pool.alloc(10, Alignment::Bytes4).unwrap();
395        let h2 = pool.alloc(10, Alignment::Bytes4).unwrap();
396        assert!(h2.id > h1.id);
397    }
398
399    #[test]
400    fn test_block_coalescing_after_free() {
401        let mut pool = GpuMemoryPool::new(256);
402        let h1 = pool.alloc(64, Alignment::Bytes4).unwrap();
403        let h2 = pool.alloc(64, Alignment::Bytes4).unwrap();
404        pool.free(&h1);
405        pool.free(&h2);
406        // After freeing both, the pool should be able to allocate a 128-byte block again.
407        let h3 = pool.alloc(100, Alignment::Bytes4);
408        assert!(h3.is_some());
409    }
410
411    #[test]
412    fn test_stats_free_bytes() {
413        let mut stats = PoolStats {
414            total_reserved: 1000,
415            total_allocated: 400,
416            ..Default::default()
417        };
418        assert_eq!(stats.free_bytes(), 600);
419        stats.total_allocated = 1000;
420        assert_eq!(stats.free_bytes(), 0);
421    }
422}