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    /// Defragment the pool by coalescing free ranges in all blocks
301    /// and removing completely empty blocks.
302    ///
303    /// This operation is O(B * R log R) where B = block count, R = free ranges.
304    /// Call periodically in long-running sessions to reduce fragmentation.
305    ///
306    /// Returns a `DefragResult` describing the work performed.
307    #[allow(dead_code)]
308    pub fn defragment(&mut self) -> DefragResult {
309        let mut ranges_coalesced = 0u64;
310        let bytes_before_free = self.free_bytes();
311
312        // Coalesce free ranges within each block.
313        for block in &mut self.blocks {
314            let ranges_before = block.free_ranges.len();
315            block.coalesce();
316            let ranges_after = block.free_ranges.len();
317            if ranges_before > ranges_after {
318                ranges_coalesced += (ranges_before - ranges_after) as u64;
319            }
320        }
321
322        // Remove blocks that are completely free (no live allocations).
323        let blocks_before = self.blocks.len();
324        self.blocks.retain(|b| b.live_count > 0);
325        let blocks_after = self.blocks.len();
326        let blocks_removed = (blocks_before - blocks_after) as u64;
327
328        // Update stats.
329        if blocks_removed > 0 {
330            self.stats.block_count = self.blocks.len();
331            self.stats.total_reserved = self.blocks.iter().map(|b| b.capacity).sum();
332        }
333
334        let bytes_after_free = self.free_bytes();
335
336        DefragResult {
337            ranges_coalesced,
338            blocks_removed,
339            bytes_recovered: bytes_after_free.saturating_sub(bytes_before_free),
340            fragmentation_ratio: self.fragmentation_ratio(),
341        }
342    }
343
344    /// Compact the pool by migrating allocations from sparsely-used blocks
345    /// into denser blocks, freeing up empty blocks.
346    ///
347    /// This is a more aggressive form of `defragment()` — it returns a list
348    /// of migrations (old_handle, new_handle) that the caller must apply to
349    /// move data from old offsets to new offsets.
350    ///
351    /// Returns `None` if compaction is not possible or not beneficial.
352    #[allow(dead_code)]
353    pub fn compact(&mut self) -> Option<CompactionPlan> {
354        // First, defragment to coalesce free ranges.
355        let defrag = self.defragment();
356
357        if self.blocks.len() <= 1 {
358            return Some(CompactionPlan {
359                migrations: Vec::new(),
360                defrag_result: defrag,
361            });
362        }
363
364        // Find blocks with low utilization (< 50% used).
365        let mut sparse_blocks: Vec<usize> = Vec::new();
366        for (i, block) in self.blocks.iter().enumerate() {
367            let used = block.capacity - block.free_bytes();
368            let util = if block.capacity > 0 {
369                used as f64 / block.capacity as f64
370            } else {
371                1.0
372            };
373            if util < 0.5 && block.live_count > 0 {
374                sparse_blocks.push(i);
375            }
376        }
377
378        if sparse_blocks.is_empty() {
379            return Some(CompactionPlan {
380                migrations: Vec::new(),
381                defrag_result: defrag,
382            });
383        }
384
385        // For each sparse block, try to migrate its allocations elsewhere.
386        // (The actual migration requires caller cooperation — we just plan.)
387        let migrations: Vec<MigrationEntry> = sparse_blocks
388            .iter()
389            .map(|&block_idx| {
390                let used = self.blocks[block_idx].capacity - self.blocks[block_idx].free_bytes();
391                MigrationEntry {
392                    source_block: block_idx,
393                    estimated_bytes: used,
394                }
395            })
396            .collect();
397
398        Some(CompactionPlan {
399            migrations,
400            defrag_result: defrag,
401        })
402    }
403
404    /// Calculate the fragmentation ratio (0.0 = no fragmentation, 1.0 = fully fragmented).
405    ///
406    /// Defined as: 1 - (largest_free_contiguous / total_free).
407    /// A value of 0.0 means all free memory is in one contiguous block.
408    #[allow(dead_code)]
409    #[must_use]
410    pub fn fragmentation_ratio(&self) -> f64 {
411        let total_free = self.free_bytes();
412        if total_free == 0 {
413            return 0.0;
414        }
415
416        let largest_contiguous: usize = self
417            .blocks
418            .iter()
419            .flat_map(|b| b.free_ranges.iter().map(|r| r.size))
420            .max()
421            .unwrap_or(0);
422
423        if total_free == 0 {
424            0.0
425        } else {
426            1.0 - (largest_contiguous as f64 / total_free as f64)
427        }
428    }
429
430    /// Shrink the pool by removing empty blocks and trimming capacity.
431    ///
432    /// Unlike `defragment`, this only removes blocks with zero live allocations.
433    #[allow(dead_code)]
434    pub fn shrink(&mut self) -> usize {
435        let before = self.blocks.len();
436        self.blocks.retain(|b| b.live_count > 0);
437        let removed = before - self.blocks.len();
438        if removed > 0 {
439            self.stats.block_count = self.blocks.len();
440            self.stats.total_reserved = self.blocks.iter().map(|b| b.capacity).sum();
441        }
442        removed
443    }
444}
445
446/// Result of a defragmentation operation.
447#[allow(dead_code)]
448#[derive(Debug, Clone)]
449pub struct DefragResult {
450    /// Number of free ranges that were coalesced.
451    pub ranges_coalesced: u64,
452    /// Number of completely empty blocks that were removed.
453    pub blocks_removed: u64,
454    /// Approximate bytes recovered by coalescing (usable contiguous space gained).
455    pub bytes_recovered: usize,
456    /// Fragmentation ratio after defragmentation (0.0 = perfect, 1.0 = fully fragmented).
457    pub fragmentation_ratio: f64,
458}
459
460/// A planned migration for compaction.
461#[allow(dead_code)]
462#[derive(Debug, Clone)]
463pub struct MigrationEntry {
464    /// Index of the source block to evacuate.
465    pub source_block: usize,
466    /// Estimated bytes to migrate.
467    pub estimated_bytes: usize,
468}
469
470/// Plan returned by `compact()`.
471#[allow(dead_code)]
472#[derive(Debug, Clone)]
473pub struct CompactionPlan {
474    /// List of migrations to perform.
475    pub migrations: Vec<MigrationEntry>,
476    /// Defragmentation result from the initial pass.
477    pub defrag_result: DefragResult,
478}
479
480// ---------------------------------------------------------------------------
481// Unit tests
482// ---------------------------------------------------------------------------
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn test_alignment_align_up() {
490        assert_eq!(Alignment::Bytes16.align_up(0), 0);
491        assert_eq!(Alignment::Bytes16.align_up(1), 16);
492        assert_eq!(Alignment::Bytes16.align_up(16), 16);
493        assert_eq!(Alignment::Bytes16.align_up(17), 32);
494    }
495
496    #[test]
497    fn test_alignment_as_usize() {
498        assert_eq!(Alignment::Bytes4.as_usize(), 4);
499        assert_eq!(Alignment::Bytes256.as_usize(), 256);
500    }
501
502    #[test]
503    fn test_simple_alloc() {
504        let mut pool = GpuMemoryPool::new(1024);
505        let handle = pool.alloc(64, Alignment::Bytes16);
506        assert!(handle.is_some());
507        let h = handle.expect("handle should be valid");
508        assert_eq!(h.size, 64);
509        assert_eq!(h.offset % 16, 0);
510    }
511
512    #[test]
513    fn test_zero_size_alloc_returns_none() {
514        let mut pool = GpuMemoryPool::new(1024);
515        assert!(pool.alloc(0, Alignment::Bytes4).is_none());
516    }
517
518    #[test]
519    fn test_alloc_and_free_stats() {
520        let mut pool = GpuMemoryPool::new(1024);
521        let h = pool
522            .alloc(100, Alignment::Bytes4)
523            .expect("allocation should succeed");
524        assert_eq!(pool.stats().total_allocated, 100);
525        pool.free(&h);
526        assert_eq!(pool.stats().total_allocated, 0);
527    }
528
529    #[test]
530    fn test_multiple_allocs_same_block() {
531        let mut pool = GpuMemoryPool::new(4096);
532        let h1 = pool
533            .alloc(128, Alignment::Bytes64)
534            .expect("allocation should succeed");
535        let h2 = pool
536            .alloc(128, Alignment::Bytes64)
537            .expect("allocation should succeed");
538        assert_eq!(h1.block_index, h2.block_index);
539        assert_eq!(pool.block_count(), 1);
540    }
541
542    #[test]
543    fn test_new_block_created_when_full() {
544        let mut pool = GpuMemoryPool::new(64);
545        // First alloc fills the initial block.
546        let _h1 = pool
547            .alloc(64, Alignment::Bytes4)
548            .expect("allocation should succeed");
549        // Second alloc must create a new block.
550        let h2 = pool
551            .alloc(64, Alignment::Bytes4)
552            .expect("allocation should succeed");
553        assert!(h2.block_index >= 1 || pool.block_count() == 2);
554    }
555
556    #[test]
557    fn test_pool_stats_utilisation() {
558        let mut pool = GpuMemoryPool::new(1000);
559        pool.alloc(500, Alignment::Bytes4);
560        let util = pool.stats().utilisation();
561        assert!(util > 0.0 && util <= 1.0);
562    }
563
564    #[test]
565    fn test_free_bytes_decreases_after_alloc() {
566        let mut pool = GpuMemoryPool::new(1024);
567        pool.alloc(256, Alignment::Bytes4);
568        assert!(pool.free_bytes() < 1024);
569    }
570
571    #[test]
572    fn test_reset_clears_all() {
573        let mut pool = GpuMemoryPool::new(512);
574        pool.alloc(100, Alignment::Bytes4);
575        pool.reset();
576        assert_eq!(pool.block_count(), 0);
577        assert_eq!(pool.stats().alloc_count, 0);
578    }
579
580    #[test]
581    fn test_alloc_id_increments() {
582        let mut pool = GpuMemoryPool::new(1024);
583        let h1 = pool
584            .alloc(10, Alignment::Bytes4)
585            .expect("allocation should succeed");
586        let h2 = pool
587            .alloc(10, Alignment::Bytes4)
588            .expect("allocation should succeed");
589        assert!(h2.id > h1.id);
590    }
591
592    #[test]
593    fn test_block_coalescing_after_free() {
594        let mut pool = GpuMemoryPool::new(256);
595        let h1 = pool
596            .alloc(64, Alignment::Bytes4)
597            .expect("allocation should succeed");
598        let h2 = pool
599            .alloc(64, Alignment::Bytes4)
600            .expect("allocation should succeed");
601        pool.free(&h1);
602        pool.free(&h2);
603        // After freeing both, the pool should be able to allocate a 128-byte block again.
604        let h3 = pool.alloc(100, Alignment::Bytes4);
605        assert!(h3.is_some());
606    }
607
608    #[test]
609    fn test_stats_free_bytes() {
610        let mut stats = PoolStats {
611            total_reserved: 1000,
612            total_allocated: 400,
613            ..Default::default()
614        };
615        assert_eq!(stats.free_bytes(), 600);
616        stats.total_allocated = 1000;
617        assert_eq!(stats.free_bytes(), 0);
618    }
619
620    // --- Defragmentation tests ---
621
622    #[test]
623    fn test_defragment_empty_pool() {
624        let mut pool = GpuMemoryPool::new(1024);
625        let result = pool.defragment();
626        assert_eq!(result.ranges_coalesced, 0);
627        assert_eq!(result.blocks_removed, 0);
628    }
629
630    #[test]
631    fn test_defragment_removes_empty_blocks() {
632        let mut pool = GpuMemoryPool::new(256);
633        // Allocate in two blocks.
634        let h1 = pool.alloc(256, Alignment::Bytes4).expect("alloc 1");
635        let _h2 = pool.alloc(256, Alignment::Bytes4).expect("alloc 2");
636        assert_eq!(pool.block_count(), 2);
637
638        // Free the first block entirely.
639        pool.free(&h1);
640        let result = pool.defragment();
641        assert_eq!(result.blocks_removed, 1);
642        assert_eq!(pool.block_count(), 1);
643    }
644
645    #[test]
646    fn test_defragment_coalesces_ranges() {
647        let mut pool = GpuMemoryPool::new(1024);
648        let h1 = pool.alloc(64, Alignment::Bytes4).expect("alloc 1");
649        let h2 = pool.alloc(64, Alignment::Bytes4).expect("alloc 2");
650        let h3 = pool.alloc(64, Alignment::Bytes4).expect("alloc 3");
651
652        // Free alternating to create fragmentation.
653        pool.free(&h1);
654        pool.free(&h3);
655
656        let frag_before = pool.fragmentation_ratio();
657
658        // Free the middle one too → all should coalesce.
659        pool.free(&h2);
660        let result = pool.defragment();
661
662        // After all three freed and coalesced, block should be empty → removed.
663        assert!(result.ranges_coalesced > 0 || result.blocks_removed > 0);
664        let frag_after = pool.fragmentation_ratio();
665        assert!(
666            frag_after <= frag_before || frag_after == 0.0,
667            "fragmentation should not increase: before={frag_before}, after={frag_after}"
668        );
669    }
670
671    #[test]
672    fn test_fragmentation_ratio_no_fragmentation() {
673        let mut pool = GpuMemoryPool::new(1024);
674        // One contiguous free block.
675        pool.alloc(100, Alignment::Bytes4);
676        let ratio = pool.fragmentation_ratio();
677        // All free space is in one contiguous range → ratio should be 0.
678        assert!(ratio == 0.0, "expected 0.0 fragmentation, got {ratio}");
679    }
680
681    #[test]
682    fn test_fragmentation_ratio_with_holes() {
683        let mut pool = GpuMemoryPool::new(1024);
684        let h1 = pool.alloc(100, Alignment::Bytes4).expect("alloc 1");
685        let _h2 = pool.alloc(100, Alignment::Bytes4).expect("alloc 2");
686        let h3 = pool.alloc(100, Alignment::Bytes4).expect("alloc 3");
687
688        // Free h1 and h3 → two non-adjacent free ranges → fragmentation > 0.
689        pool.free(&h1);
690        pool.free(&h3);
691
692        let ratio = pool.fragmentation_ratio();
693        assert!(ratio > 0.0, "should have fragmentation, got {ratio}");
694        assert!(ratio <= 1.0);
695    }
696
697    #[test]
698    fn test_shrink_removes_empty_blocks() {
699        let mut pool = GpuMemoryPool::new(128);
700        let h1 = pool.alloc(128, Alignment::Bytes4).expect("alloc 1");
701        let _h2 = pool.alloc(128, Alignment::Bytes4).expect("alloc 2");
702        pool.free(&h1);
703
704        let removed = pool.shrink();
705        assert_eq!(removed, 1);
706        assert_eq!(pool.block_count(), 1);
707    }
708
709    #[test]
710    fn test_compact_returns_plan() {
711        let mut pool = GpuMemoryPool::new(1024);
712        let h1 = pool.alloc(100, Alignment::Bytes4).expect("alloc 1");
713        let _ = h1; // keep it live
714
715        let plan = pool.compact();
716        assert!(plan.is_some());
717    }
718
719    #[test]
720    fn test_compact_identifies_sparse_blocks() {
721        let mut pool = GpuMemoryPool::new(1024);
722        // Allocate a small amount in a large block → sparse.
723        let h1 = pool.alloc(50, Alignment::Bytes4).expect("alloc");
724        // Force a second block.
725        let _h2 = pool.alloc(1024, Alignment::Bytes4).expect("alloc 2");
726
727        let _ = h1;
728        let plan = pool.compact().expect("compact should succeed");
729        // Block 0 uses 50/1024 = ~5% → should be identified as sparse.
730        let has_sparse = plan.migrations.iter().any(|m| m.source_block == 0);
731        assert!(has_sparse, "block 0 should be identified as sparse");
732    }
733
734    #[test]
735    fn test_defragment_updates_stats() {
736        let mut pool = GpuMemoryPool::new(256);
737        let h1 = pool.alloc(256, Alignment::Bytes4).expect("alloc 1");
738        let _h2 = pool.alloc(256, Alignment::Bytes4).expect("alloc 2");
739        pool.free(&h1);
740
741        let result = pool.defragment();
742        assert_eq!(result.blocks_removed, 1);
743        assert_eq!(pool.stats().block_count, 1);
744    }
745}