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
8 changes: 7 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
- name: Vet allocator sources
working-directory: source/modules/antono2/vkmemalloc
# V 0.5.2's directory-mode vet parser does not accept aliased imports.
run: v vet block_pool.v block_pool_test.v mapped_memory.v mapped_memory_test.v memory_policy.v memory_policy_test.v upload_ring.v upload_ring_test.v vulkan_memory_allocator.v vulkan_memory_allocator_test.v
run: v vet block_pool.v block_pool_test.v block_pool_stress_test.v diagnostics.v diagnostics_test.v mapped_memory.v mapped_memory_test.v memory_policy.v memory_policy_test.v upload_ring.v upload_ring_test.v vulkan_memory_allocator.v vulkan_memory_allocator_test.v
- name: Run allocator tests
working-directory: source/modules/antono2/vkmemalloc
run: v run setup.vsh --check
Expand All @@ -50,6 +50,12 @@ jobs:
lavapipe_icd=$(find /usr/share/vulkan/icd.d -name 'lvp_icd*.json' -print -quit)
test -n "$lavapipe_icd"
VK_ICD_FILENAMES="$lavapipe_icd" v run examples/buffer_suballocation
- name: Run sustained Vulkan allocation workload
working-directory: source/modules/antono2/vkmemalloc
run: |
lavapipe_icd=$(find /usr/share/vulkan/icd.d -name 'lvp_icd*.json' -print -quit)
test -n "$lavapipe_icd"
VK_ICD_FILENAMES="$lavapipe_icd" v run examples/stress

sanitizers:
runs-on: ubuntu-24.04
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

All notable changes to this project will be documented in this file.

## 2.5.0 - 2026-09-12

- Add cumulative allocation, fallback, block-reuse, trim, and high-water-mark
counters alongside current allocator statistics.
- Add an opt-in bounded lifecycle event trace with deterministic sequence
numbers, chronological snapshots, overwrite accounting, and resettable
measurement windows.
- Add a 30,000-operation deterministic block-planner workload that continuously
verifies ownership, overlap, accounting, memory-type isolation, and complete
coalescing.
- Extend the lavapipe integration example with 1,536 real Vulkan buffer
allocations, mapped writes, flushes, fragmented reuse, and trace validation.
- Keep tracing disabled by default and retain the allocator's existing external
synchronization contract.

## 2.4.0 - 2026-09-12

- Add explainable, deterministic memory-type ranking for GPU-only, upload,
Expand Down
72 changes: 64 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ The allocator has three deliberately separate layers:
3. **Vulkan ownership** creates, maps, binds, and frees `VkDeviceMemory` while
`AllocationInfo` keeps the selected type, heap, properties, block size, and
private ownership record together.
4. **Observability** exposes current occupancy separately from cumulative
activity and an optional bounded event trace. Normal applications pay no
trace-storage cost unless they opt in.

Images remain dedicated. That conservative rule avoids hiding the additional
tiling and buffer-image granularity rules that a safe image suballocator would
Expand Down Expand Up @@ -67,6 +70,8 @@ mut allocator := vma.new(vma.AllocatorCreateInfo{
physical_device: physical_device
device: device
preferred_block_size: 64 * 1024 * 1024
// Optional: retain the latest allocator lifecycle events for diagnostics.
event_trace_capacity: 256
})
```

Expand Down Expand Up @@ -197,6 +202,41 @@ 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.

### Activity counters and event traces

`diagnostics()` combines current `AllocatorStats` with cumulative counters and
high-water marks. Counters are always collected and make it possible to answer
questions such as whether workload growth came from block reuse or additional
`VkDeviceMemory` objects:

```v
diagnostics := allocator.diagnostics()
println('live: ${diagnostics.current.allocation_count}')
println('peak live: ${diagnostics.counters.peak_allocation_count}')
println('new blocks: ${diagnostics.counters.block_allocations}')
println('block reuses: ${diagnostics.counters.block_reuses}')
println('fallback attempts: ${diagnostics.counters.fallback_attempts}')
```

Set `event_trace_capacity` in `AllocatorCreateInfo` to retain the latest
allocation success, failure, release, and trim events. The storage is a bounded
ring: old records are overwritten, `dropped_event_count` reports how many were
replaced, and `recent_events()` always returns the retained records in
chronological order. Events use a monotonic sequence rather than a wall-clock
timestamp so traces remain deterministic and callers can add the timing system
appropriate to their application.

```v
for event in allocator.recent_events() {
println('#${event.sequence} ${event.kind}: size=${event.requested_size}, type=${event.memory_type}, result=${event.result}')
}
```

`reset_diagnostics()` starts a new measurement window without affecting live
resources. It clears the trace and cumulative activity, then seeds high-water
marks from the allocator's current state. Returned diagnostics and event arrays
are snapshots; modifying them does not change the allocator.

### Heap budgets

`VK_EXT_memory_budget` exposes driver estimates for current heap usage and the
Expand Down Expand Up @@ -277,13 +317,18 @@ non-coherent memory correctly.

## Ownership and limitations

- Keep and pass the single `Allocator` instance returned by `new()`; do not copy
it after allocations begin. Copies would refer to the same Vulkan blocks and
planner while carrying separate mutable bookkeeping.
- An `AllocationInfo` belongs to the allocator that created it.
- A successful `release()` clears the complete `AllocationInfo` and prevents a
second free through that record.
- The allocator tracks at most 256 memory blocks by default. Each block can
contain many suballocations.
- The allocator is not internally synchronized. Externally synchronize access
when multiple threads can allocate or free concurrently.
when multiple threads can allocate, free, query diagnostics, or reset the
diagnostic window concurrently. This keeps synchronization ownership with
the renderer, which normally already serializes Vulkan device-memory calls.
- Concurrently mapped allocations in one shared block reuse a single underlying
Vulkan mapping. Each successful `map()` must have a matching `unmap()`.
- The allocator does not relocate live resources or suballocate images.
Expand All @@ -297,21 +342,32 @@ errors instead of assuming allocation succeeds.

## Tests

The bookkeeping tests do not require a Vulkan-capable GPU:
The bookkeeping tests do not require a Vulkan-capable GPU. They include a
30,000-operation mixed-size, mixed-alignment workload across multiple memory
types and continuously verify ownership, non-overlap, accounting, and
coalescing invariants:

```sh
v test .
```

The runnable example enables live budgets when available, creates two real
policy-selected upload buffers, verifies that they share a memory block, maps
and flushes them, creates a dedicated GPU-only image, prints heap diagnostics,
then exercises a persistently mapped upload ring through wraparound and FIFO
retirement:
The introductory runnable example enables live budgets when available, creates
two real policy-selected upload buffers, verifies that they share a memory
block, maps and flushes them, creates a dedicated GPU-only image, and prints
heap diagnostics and allocation counters. It also exercises a persistently
mapped upload ring through wraparound and FIFO retirement:

```sh
v run examples/buffer_suballocation
```

CI executes this example against Mesa's CPU Vulkan implementation, so it does
The [separate sustained workload](examples/stress/main.v) performs 1,536 real
Vulkan buffer allocations with mapped writes, flushes, fragmenting
release/refill cycles, bounded-trace wraparound, and final coalescing checks:

```sh
v run examples/stress
```

CI executes both examples against Mesa's CPU Vulkan implementation, so it does
not depend on access to a hardware GPU.
79 changes: 79 additions & 0 deletions block_pool_stress_test.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
module vkmemalloc

fn assert_block_pool_invariants(pool &MemoryBlockPool, active []BlockReservation, committed u64) {
stats := pool.stats()
assert stats.block_count == 4
assert stats.allocation_count == active.len
assert stats.committed == committed
assert stats.used + stats.free == committed
assert stats.largest_free_range <= stats.free
mut used := u64(0)
for index, reservation in active {
assert pool.contains(reservation)
used += reservation.size
for other in active[index + 1..] {
if reservation.block_id == other.block_id {
assert reservation.offset + reservation.size <= other.offset
|| other.offset + other.size <= reservation.offset
}
}
}
assert stats.used == used
type_0 := pool.stats_for_memory_type(0)
type_1 := pool.stats_for_memory_type(1)
assert type_0.block_count == 2
assert type_1.block_count == 2
assert type_0.allocation_count + type_1.allocation_count == active.len
assert type_0.committed + type_1.committed == committed
assert type_0.used + type_1.used == used
}

fn test_block_pool_sustained_mixed_workload() {
block_size := u64(4096)
mut pool := new_memory_block_pool(block_size, 4) or { panic(err) }
for _ in 0 .. 2 {
_ = pool.add_block(0, block_size) or { panic(err) }
_ = pool.add_block(1, block_size) or { panic(err) }
}
committed := block_size * 4
alignments := [u64(1), 2, 3, 4, 8, 16, 31, 64, 128, 256]
mut active := []BlockReservation{}
mut state := u32(0xa110ca7e)
mut successful_allocations := 0

for step in 0 .. 30_000 {
state = state * 1_664_525 + 1_013_904_223
if active.len > 0 && state % 3 == 0 {
index := int((state >> 8) % u32(active.len))
assert pool.release(active[index])
active.delete(index)
} else {
memory_type := (state >> 4) & 1
size := u64(1 + (state >> 12) % 257)
alignment := alignments[int((state >> 24) % u32(alignments.len))]
if reservation := pool.reserve(memory_type, size, alignment) {
assert reservation.offset % alignment == 0
active << reservation
successful_allocations++
} else if active.len > 0 {
index := int((state >> 8) % u32(active.len))
assert pool.release(active[index])
active.delete(index)
}
}
if step % 200 == 0 {
assert_block_pool_invariants(pool, active, committed)
}
}

assert successful_allocations > 5_000
for reservation in active {
assert pool.release(reservation)
}
assert_block_pool_invariants(pool, [], committed)
final_stats := pool.stats()
assert final_stats.used == 0
assert final_stats.free_range_count == 4
assert final_stats.largest_free_range == block_size
assert final_stats.empty_block_count == 4
}
Loading