Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 42 additions & 12 deletions block_pool.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
19 changes: 19 additions & 0 deletions block_pool_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}
5 changes: 4 additions & 1 deletion examples/buffer_suballocation/main.v
Original file line number Diff line number Diff line change
Expand Up @@ -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')!
Expand Down
2 changes: 1 addition & 1 deletion v.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
50 changes: 39 additions & 11 deletions vulkan_memory_allocator.v
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
}

Expand Down
14 changes: 13 additions & 1 deletion vulkan_memory_allocator_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down