diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e1096..cd0917e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## 2.3.1 - 2026-09-11 + +- Expose global and per-memory-type free-range, largest-contiguous-range, and + empty-block diagnostics without changing allocation policy. + ## 2.3.0 - 2026-09-11 - Migrate the allocation-policy dependency to the canonical `antono2.memory` diff --git a/README.md b/README.md index 53579ee..b777bfa 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,30 @@ stats := allocator.stats() println('blocks: ${stats.block_count}') println('allocations: ${stats.allocation_count}') println('committed: ${stats.committed}, used: ${stats.used}, free: ${stats.free}') +println('largest free range: ${stats.largest_free_range}') ``` `committed` is memory obtained through `vkAllocateMemory`; `used` is the sum of -live resource ranges. Free bytes may be fragmented across blocks. +live resource ranges. `free_range_count`, `largest_free_range`, and +`empty_block_count` make cached capacity and external fragmentation visible. +The largest range is measured before applying the alignment of a future +request, so it is diagnostic rather than a guarantee that an allocation will +succeed. + +Global free space can also belong to an incompatible Vulkan memory type. When +diagnosing a failed request, inspect the type selected for a comparable +allocation: + +```v +type_stats := allocator.stats_for_memory_type(allocation.mem_type) +println('type ${allocation.mem_type}: free=${type_stats.free}, largest=${type_stats.largest_free_range}') +``` + +If total compatible free space is large enough but its largest range is too +small, the existing blocks are externally fragmented. If an empty block is +reported, `trim_empty_blocks()` can return it to Vulkan before retrying another +memory class. The allocator may still create a new compatible block when its +configured block limit and the Vulkan device allow it. ## Persistent upload ring diff --git a/block_pool.v b/block_pool.v index 87ffe5d..f6a7597 100644 --- a/block_pool.v +++ b/block_pool.v @@ -21,11 +21,14 @@ struct MemoryBlock { } struct BlockPoolStats { - block_count int - allocation_count int - committed u64 - used u64 - free u64 + block_count int + allocation_count int + committed u64 + used u64 + free u64 + free_range_count int + largest_free_range u64 + empty_block_count int } // MemoryBlockPool plans suballocations without owning Vulkan handles. Keeping @@ -212,20 +215,47 @@ fn (mut pool MemoryBlockPool) remove_empty_block(block_id u64) bool { } fn (pool &MemoryBlockPool) stats() BlockPoolStats { + return pool.collect_stats(0, false) +} + +fn (pool &MemoryBlockPool) stats_for_memory_type(memory_type u32) BlockPoolStats { + return pool.collect_stats(memory_type, true) +} + +fn (pool &MemoryBlockPool) collect_stats(memory_type u32, filter_by_memory_type bool) BlockPoolStats { + mut block_count := 0 mut allocation_count := 0 mut committed := u64(0) mut used := u64(0) + mut free_range_count := 0 + mut largest_free_range := u64(0) + mut empty_block_count := 0 for block in pool.blocks { - allocation_count += block.ranges.allocation_count() + if filter_by_memory_type && block.memory_type != memory_type { + continue + } + block_count++ + range_stats := block.ranges.stats() + allocation_count += range_stats.allocation_count committed += block.capacity - used += block.ranges.used_bytes() + used += range_stats.used + free_range_count += range_stats.free_range_count + if range_stats.largest_free_range > largest_free_range { + largest_free_range = range_stats.largest_free_range + } + if range_stats.allocation_count == 0 { + empty_block_count++ + } } return BlockPoolStats{ - block_count: pool.blocks.len - allocation_count: allocation_count - committed: committed - used: used - free: committed - used + block_count: block_count + allocation_count: allocation_count + committed: committed + used: used + free: committed - used + free_range_count: free_range_count + largest_free_range: largest_free_range + empty_block_count: empty_block_count } } diff --git a/block_pool_test.v b/block_pool_test.v index 066c14a..079a5ba 100644 --- a/block_pool_test.v +++ b/block_pool_test.v @@ -42,6 +42,22 @@ fn test_block_pool_suballocates_by_memory_type_and_alignment() { assert stats.committed == 128 assert stats.used == 37 assert stats.free == 91 + assert stats.free_range_count == 3 + assert stats.largest_free_range == 56 + assert stats.empty_block_count == 0 + + device_stats := pool.stats_for_memory_type(2) + assert device_stats.block_count == 1 + assert device_stats.allocation_count == 2 + assert device_stats.committed == 64 + assert device_stats.used == 29 + assert device_stats.free == 35 + assert device_stats.free_range_count == 2 + assert device_stats.largest_free_range == 32 + assert device_stats.empty_block_count == 0 + + missing_stats := pool.stats_for_memory_type(99) + assert missing_stats == BlockPoolStats{} } fn test_block_pool_reports_exhaustion_without_mutation() { @@ -140,4 +156,7 @@ fn test_block_pool_reuses_coalesced_space_deterministically() { assert stats.allocation_count == 0 assert stats.used == 0 assert stats.free == 256 + assert stats.free_range_count == 1 + assert stats.largest_free_range == 256 + assert stats.empty_block_count == 1 } diff --git a/examples/buffer_suballocation/main.v b/examples/buffer_suballocation/main.v index 18f42a4..e0bdb6e 100644 --- a/examples/buffer_suballocation/main.v +++ b/examples/buffer_suballocation/main.v @@ -105,7 +105,10 @@ fn run() ! { stats := allocator.stats() assert stats.block_count == 1 assert stats.allocation_count == 2 - println('two buffers share one block: committed=${stats.committed}, used=${stats.used}') + type_stats := allocator.stats_for_memory_type(first_allocation.mem_type) + assert type_stats == stats + assert type_stats.largest_free_range <= type_stats.free + println('two buffers share one block: committed=${stats.committed}, used=${stats.used}, largest_free_range=${type_stats.largest_free_range}') mut first_mapped := voidptr(unsafe { nil }) mut second_mapped := voidptr(unsafe { nil }) require_success(allocator.map(mut first_allocation, &first_mapped), 'map first staging buffer')! diff --git a/v.mod b/v.mod index 4cbee2b..1ffac26 100644 --- a/v.mod +++ b/v.mod @@ -2,7 +2,7 @@ Module { name: 'antono2.vkmemalloc' author: 'Anton Oreskin' description: 'Vulkan block suballocation helpers for buffers and images' - version: '2.3.0' + version: '2.3.1' license: 'MIT' repo_url: 'https://github.com/antono2/vulkan_memory_allocator' tags: ['V','vulkan','allocator'] diff --git a/vulkan_memory_allocator.v b/vulkan_memory_allocator.v index 1a3aaa9..bb25be8 100644 --- a/vulkan_memory_allocator.v +++ b/vulkan_memory_allocator.v @@ -554,14 +554,19 @@ pub fn (mut a Allocator) trim_empty_blocks() int { return removed } -// AllocatorStats reports Vulkan block commitment and live suballocation use. +// AllocatorStats reports Vulkan block commitment, live suballocation use, and +// free-range fragmentation. largest_free_range is the largest raw contiguous +// range and does not account for the alignment of a future request. pub struct AllocatorStats { pub: - block_count int - allocation_count int - committed u64 - used u64 - free u64 + block_count int + allocation_count int + committed u64 + used u64 + free u64 + free_range_count int + largest_free_range u64 + empty_block_count int } // stats returns current Vulkan commitment and suballocation occupancy. @@ -571,11 +576,34 @@ pub fn (a &Allocator) stats() AllocatorStats { } stats := a.planner.stats() return AllocatorStats{ - block_count: stats.block_count - allocation_count: stats.allocation_count - committed: stats.committed - used: stats.used - free: stats.free + block_count: stats.block_count + allocation_count: stats.allocation_count + committed: stats.committed + used: stats.used + free: stats.free + free_range_count: stats.free_range_count + largest_free_range: stats.largest_free_range + empty_block_count: stats.empty_block_count + } +} + +// stats_for_memory_type returns commitment, occupancy, and fragmentation for +// one Vulkan memory-type index. Use this instead of global stats when +// diagnosing whether compatible blocks can satisfy a resource request. +pub fn (a &Allocator) stats_for_memory_type(memory_type u32) AllocatorStats { + if isnil(a.planner) { + return AllocatorStats{} + } + stats := a.planner.stats_for_memory_type(memory_type) + return AllocatorStats{ + block_count: stats.block_count + allocation_count: stats.allocation_count + committed: stats.committed + used: stats.used + free: stats.free + free_range_count: stats.free_range_count + largest_free_range: stats.largest_free_range + empty_block_count: stats.empty_block_count } } diff --git a/vulkan_memory_allocator_test.v b/vulkan_memory_allocator_test.v index 914264b..1fed4a6 100644 --- a/vulkan_memory_allocator_test.v +++ b/vulkan_memory_allocator_test.v @@ -69,6 +69,11 @@ fn test_allocator_release_returns_only_the_suballocated_range() { assert before.allocation_count == 2 assert before.committed == 64 assert before.used == 32 + assert before.free_range_count == 1 + assert before.largest_free_range == 32 + assert before.empty_block_count == 0 + assert allocator.stats_for_memory_type(4) == before + assert allocator.stats_for_memory_type(99) == AllocatorStats{} first_released := allocator.release(mut first) assert first_released @@ -79,10 +84,17 @@ fn test_allocator_release_returns_only_the_suballocated_range() { assert after.allocation_count == 1 assert after.used == 16 assert after.free == 48 + assert after.free_range_count == 2 + assert after.largest_free_range == 32 + assert after.empty_block_count == 0 second_released := allocator.release(mut second) assert second_released - assert allocator.stats().allocation_count == 0 + final_stats := allocator.stats() + assert final_stats.allocation_count == 0 + assert final_stats.free_range_count == 1 + assert final_stats.largest_free_range == 64 + assert final_stats.empty_block_count == 1 } fn test_allocator_rejects_forged_public_allocation_fields() {