diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 94c3fb4..798436c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ae39f67..ab838bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/README.md b/README.md index 038244b..ecdbcf4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 }) ``` @@ -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 @@ -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. @@ -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. diff --git a/block_pool_stress_test.v b/block_pool_stress_test.v new file mode 100644 index 0000000..a4376f4 --- /dev/null +++ b/block_pool_stress_test.v @@ -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 +} diff --git a/diagnostics.v b/diagnostics.v new file mode 100644 index 0000000..6447a91 --- /dev/null +++ b/diagnostics.v @@ -0,0 +1,268 @@ +module vkmemalloc + +import antono2.vulkan as vk + +// AllocatorEventKind identifies a high-signal allocator lifecycle event. +pub enum AllocatorEventKind { + allocation_succeeded + allocation_failed + allocation_released + block_trimmed +} + +// AllocatorEvent is one entry in the optional bounded diagnostic trace. +// Sequence numbers are monotonic for the lifetime of the allocator. A memory +// type or heap index of max_u32 means that no compatible choice was available. +pub struct AllocatorEvent { +pub: + sequence u64 + kind AllocatorEventKind + result vk.Result + memory_type u32 = max_u32 + heap_index u32 = max_u32 + requested_size u64 + allocation_offset u64 + block_size u64 + created_block bool + dedicated bool +} + +// AllocatorCounters contains cumulative activity and high-water marks. The +// current allocator state remains available through AllocatorStats. +pub struct AllocatorCounters { +pub: + allocation_attempts u64 + allocation_successes u64 + allocation_failures u64 + allocation_releases u64 + requested_bytes u64 + successful_bytes u64 + memory_type_attempts u64 + fallback_attempts u64 + trim_retry_attempts u64 + block_allocations u64 + block_reuses u64 + block_frees u64 + trimmed_blocks u64 + peak_allocation_count int + peak_committed u64 + peak_used u64 +} + +struct AllocatorCounterState { +mut: + allocation_attempts u64 + allocation_successes u64 + allocation_failures u64 + allocation_releases u64 + requested_bytes u64 + successful_bytes u64 + memory_type_attempts u64 + fallback_attempts u64 + trim_retry_attempts u64 + block_allocations u64 + block_reuses u64 + block_frees u64 + trimmed_blocks u64 + peak_allocation_count int + peak_committed u64 + peak_used u64 +} + +fn (c &AllocatorCounterState) snapshot() AllocatorCounters { + return AllocatorCounters{ + allocation_attempts: c.allocation_attempts + allocation_successes: c.allocation_successes + allocation_failures: c.allocation_failures + allocation_releases: c.allocation_releases + requested_bytes: c.requested_bytes + successful_bytes: c.successful_bytes + memory_type_attempts: c.memory_type_attempts + fallback_attempts: c.fallback_attempts + trim_retry_attempts: c.trim_retry_attempts + block_allocations: c.block_allocations + block_reuses: c.block_reuses + block_frees: c.block_frees + trimmed_blocks: c.trimmed_blocks + peak_allocation_count: c.peak_allocation_count + peak_committed: c.peak_committed + peak_used: c.peak_used + } +} + +// AllocatorDiagnostics combines the allocator's current state with cumulative +// counters and trace retention information. +pub struct AllocatorDiagnostics { +pub: + current AllocatorStats + counters AllocatorCounters + trace_capacity int + retained_event_count int + dropped_event_count u64 +} + +fn (mut a Allocator) next_diagnostic_sequence() u64 { + sequence := a.next_event_sequence + a.next_event_sequence++ + if a.next_event_sequence == 0 { + a.next_event_sequence = 1 + } + return sequence +} + +fn (mut a Allocator) record_event(event AllocatorEvent) { + if a.event_trace_capacity <= 0 { + return + } + entry := AllocatorEvent{ + ...event + sequence: a.next_diagnostic_sequence() + } + if a.events.len < a.event_trace_capacity { + a.events << entry + if a.events.len == a.event_trace_capacity { + a.event_cursor = 0 + } + return + } + a.events[a.event_cursor] = entry + a.event_cursor = (a.event_cursor + 1) % a.event_trace_capacity + a.dropped_event_count++ +} + +fn (mut a Allocator) begin_allocation(requested_size u64) { + a.counters_.allocation_attempts++ + a.counters_.requested_bytes += requested_size +} + +fn (mut a Allocator) note_memory_type_attempt(fallback bool) { + a.counters_.memory_type_attempts++ + if fallback { + a.counters_.fallback_attempts++ + } +} + +fn (mut a Allocator) note_allocation_success(alloc_info AllocationInfo, dedicated bool) { + a.counters_.allocation_successes++ + a.counters_.successful_bytes += alloc_info.size + a.diagnostic_live_count++ + a.diagnostic_live_used += alloc_info.size + if alloc_info.created_block { + a.diagnostic_committed += alloc_info.block_size + } + if a.diagnostic_live_count > a.counters_.peak_allocation_count { + a.counters_.peak_allocation_count = a.diagnostic_live_count + } + if a.diagnostic_committed > a.counters_.peak_committed { + a.counters_.peak_committed = a.diagnostic_committed + } + if a.diagnostic_live_used > a.counters_.peak_used { + a.counters_.peak_used = a.diagnostic_live_used + } + a.record_event(AllocatorEvent{ + kind: .allocation_succeeded + result: .success + memory_type: alloc_info.mem_type + heap_index: alloc_info.heap_index + requested_size: alloc_info.size + allocation_offset: alloc_info.offset + block_size: alloc_info.block_size + created_block: alloc_info.created_block + dedicated: dedicated + }) +} + +fn (mut a Allocator) note_allocation_failure(result vk.Result, requested_size u64, memory_type u32, heap_index u32, dedicated bool) { + a.counters_.allocation_failures++ + a.record_event(AllocatorEvent{ + kind: .allocation_failed + result: result + memory_type: memory_type + heap_index: heap_index + requested_size: requested_size + dedicated: dedicated + }) +} + +fn (mut a Allocator) note_allocation_release(alloc_info AllocationInfo, dedicated bool) { + a.counters_.allocation_releases++ + if a.diagnostic_live_count > 0 { + a.diagnostic_live_count-- + } + if alloc_info.size <= a.diagnostic_live_used { + a.diagnostic_live_used -= alloc_info.size + } + if dedicated && alloc_info.block_size <= a.diagnostic_committed { + a.diagnostic_committed -= alloc_info.block_size + } + a.record_event(AllocatorEvent{ + kind: .allocation_released + result: .success + memory_type: alloc_info.mem_type + heap_index: alloc_info.heap_index + requested_size: alloc_info.size + allocation_offset: alloc_info.offset + block_size: alloc_info.block_size + created_block: alloc_info.created_block + dedicated: dedicated + }) +} + +fn (mut a Allocator) note_block_trimmed(memory_type u32, heap_index u32, block_size u64) { + a.counters_.block_frees++ + a.counters_.trimmed_blocks++ + if block_size <= a.diagnostic_committed { + a.diagnostic_committed -= block_size + } + a.record_event(AllocatorEvent{ + kind: .block_trimmed + result: .success + memory_type: memory_type + heap_index: heap_index + block_size: block_size + }) +} + +// diagnostics returns a consistent single-threaded snapshot of current state, +// lifetime counters, and bounded-trace retention. Like the allocator itself, +// callers must externally synchronize this query with concurrent mutations. +pub fn (a &Allocator) diagnostics() AllocatorDiagnostics { + return AllocatorDiagnostics{ + current: a.stats() + counters: a.counters_.snapshot() + trace_capacity: a.event_trace_capacity + retained_event_count: a.events.len + dropped_event_count: a.dropped_event_count + } +} + +// recent_events returns retained events in chronological order. Tracing is +// disabled by default and enabled with AllocatorCreateInfo.event_trace_capacity. +pub fn (a &Allocator) recent_events() []AllocatorEvent { + if a.events.len == 0 { + return []AllocatorEvent{} + } + if a.events.len < a.event_trace_capacity || a.event_cursor == 0 { + return a.events.clone() + } + mut ordered := []AllocatorEvent{cap: a.events.len} + ordered << a.events[a.event_cursor..] + ordered << a.events[..a.event_cursor] + return ordered +} + +// reset_diagnostics starts a new measurement window. It clears the event trace +// and cumulative activity while seeding peaks from currently live allocations. +pub fn (mut a Allocator) reset_diagnostics() { + a.counters_ = AllocatorCounterState{} + current := a.stats() + a.counters_.peak_allocation_count = current.allocation_count + a.counters_.peak_committed = current.committed + a.counters_.peak_used = current.used + a.diagnostic_live_count = current.allocation_count + a.diagnostic_committed = current.committed + a.diagnostic_live_used = current.used + a.events.clear() + a.event_cursor = 0 + a.dropped_event_count = 0 +} diff --git a/diagnostics_test.v b/diagnostics_test.v new file mode 100644 index 0000000..1122a77 --- /dev/null +++ b/diagnostics_test.v @@ -0,0 +1,124 @@ +module vkmemalloc + +import antono2.vulkan as vk + +fn diagnostic_fake_memory(value usize) vk.DeviceMemory { + return unsafe { voidptr(value) } +} + +fn test_allocator_diagnostics_track_lifecycle_and_bound_the_trace() { + mut planner := new_memory_block_pool(64, 1) or { panic(err) } + block_id := planner.add_block(4, 64) or { panic(err) } + mut allocator := Allocator{ + planner: planner + event_trace_capacity: 3 + events: []AllocatorEvent{cap: 3} + } + assert allocator.remember_block(diagnostic_fake_memory(42), block_id) + allocator.reset_diagnostics() + + mut requirements := vk.MemoryRequirements{ + size: 16 + alignment: 8 + memoryTypeBits: u32(1) << 4 + } + choices := [MemoryTypeChoice{ + index: 4 + heap_index: 2 + }] + mut allocation := AllocationInfo{} + result := allocator.allocate_from_choices(mut requirements, choices, unsafe { nil }, false, + .ignore, mut allocation) + assert result == .success + assert !allocation.created_block + assert allocator.release(mut allocation) + + mut unavailable := vk.MemoryRequirements{ + size: 4 + alignment: 1 + memoryTypeBits: 1 + } + mut unavailable_allocation := AllocationInfo{} + assert allocator.allocate_from_choices(mut unavailable, [], unsafe { nil }, false, .ignore, mut + unavailable_allocation) == .error_feature_not_present + + mut invalid := vk.MemoryRequirements{ + size: 4 + memoryTypeBits: 1 + } + mut invalid_allocation := AllocationInfo{} + assert allocator.allocate_from_choices(mut invalid, choices, unsafe { nil }, false, .ignore, mut + invalid_allocation) == .error_initialization_failed + + diagnostics := allocator.diagnostics() + assert diagnostics.current.block_count == 1 + assert diagnostics.current.allocation_count == 0 + assert diagnostics.current.committed == 64 + assert diagnostics.counters.allocation_attempts == 3 + assert diagnostics.counters.allocation_successes == 1 + assert diagnostics.counters.allocation_failures == 2 + assert diagnostics.counters.allocation_releases == 1 + assert diagnostics.counters.requested_bytes == 24 + assert diagnostics.counters.successful_bytes == 16 + assert diagnostics.counters.memory_type_attempts == 1 + assert diagnostics.counters.block_reuses == 1 + assert diagnostics.counters.block_allocations == 0 + assert diagnostics.counters.peak_allocation_count == 1 + assert diagnostics.counters.peak_committed == 64 + assert diagnostics.counters.peak_used == 16 + assert diagnostics.trace_capacity == 3 + assert diagnostics.retained_event_count == 3 + assert diagnostics.dropped_event_count == 1 + + events := allocator.recent_events() + assert events.len == 3 + assert events[0].sequence == 2 + assert events[0].kind == .allocation_released + assert events[1].sequence == 3 + assert events[1].kind == .allocation_failed + assert events[1].result == .error_feature_not_present + assert events[1].memory_type == max_u32 + assert events[2].sequence == 4 + assert events[2].kind == .allocation_failed + assert events[2].result == .error_initialization_failed + + allocator.reset_diagnostics() + reset := allocator.diagnostics() + assert reset.counters.allocation_attempts == 0 + assert reset.counters.peak_allocation_count == 0 + assert reset.counters.peak_committed == 64 + assert reset.counters.peak_used == 0 + assert reset.retained_event_count == 0 + assert reset.dropped_event_count == 0 + mut after_reset := vk.MemoryRequirements{ + size: 2 + alignment: 1 + memoryTypeBits: 1 + } + mut after_reset_allocation := AllocationInfo{} + assert allocator.allocate_from_choices(mut after_reset, [], unsafe { nil }, false, .ignore, mut + after_reset_allocation) == .error_feature_not_present + after_reset_events := allocator.recent_events() + assert after_reset_events.len == 1 + assert after_reset_events[0].sequence == 5 +} + +fn test_allocator_diagnostics_are_available_when_trace_is_disabled() { + mut planner := new_memory_block_pool(64, 1) or { panic(err) } + mut allocator := Allocator{ + planner: planner + } + mut requirements := vk.MemoryRequirements{ + size: 8 + alignment: 1 + memoryTypeBits: 1 + } + mut allocation := AllocationInfo{} + assert allocator.allocate(mut requirements, .staging, mut allocation) == .error_feature_not_present + diagnostics := allocator.diagnostics() + assert diagnostics.counters.allocation_attempts == 1 + assert diagnostics.counters.allocation_failures == 1 + assert diagnostics.trace_capacity == 0 + assert diagnostics.retained_event_count == 0 + assert allocator.recent_events().len == 0 +} diff --git a/examples/buffer_suballocation/main.v b/examples/buffer_suballocation/main.v index 42bc60f..20e6de5 100644 --- a/examples/buffer_suballocation/main.v +++ b/examples/buffer_suballocation/main.v @@ -72,6 +72,7 @@ fn run() ! { device: device preferred_block_size: 4096 memory_budget_enabled: memory_budget_supported + event_trace_capacity: 16 }) defer { allocator.destroy() @@ -190,6 +191,24 @@ fn run() ! { assert allocator.trim_empty_blocks() == 1 assert allocator.stats().block_count == 0 + diagnostics := allocator.diagnostics() + assert diagnostics.current.allocation_count == 0 + assert diagnostics.counters.allocation_attempts == 3 + assert diagnostics.counters.allocation_successes == 3 + assert diagnostics.counters.allocation_failures == 0 + assert diagnostics.counters.allocation_releases == 3 + assert diagnostics.counters.block_allocations == 2 + assert diagnostics.counters.block_reuses == 1 + assert diagnostics.counters.block_allocations + diagnostics.counters.block_reuses == diagnostics.counters.allocation_successes + assert diagnostics.counters.peak_allocation_count == 3 + assert diagnostics.counters.trimmed_blocks == 1 + assert diagnostics.retained_event_count == 7 + assert diagnostics.dropped_event_count == 0 + events := allocator.recent_events() + assert events.len == 7 + assert events[0].sequence < events[events.len - 1].sequence + println('diagnostics: ${diagnostics.counters.allocation_successes} allocations, ${diagnostics.counters.block_reuses} block reuse, peak=${diagnostics.counters.peak_allocation_count}') + mut uploads := vma.new_upload_ring_with_options(mut allocator, 1024, vma.AllocationOptions{ usage: .upload })! diff --git a/examples/stress/main.v b/examples/stress/main.v new file mode 100644 index 0000000..60f9c75 --- /dev/null +++ b/examples/stress/main.v @@ -0,0 +1,160 @@ +module main + +import antono2.vkmemalloc as vma +import antono2.vulkan as vk + +const churn_cycles = 64 +const churn_batch_size = 16 + +fn require_success(result vk.Result, operation string) ! { + if result != .success { + return error('${operation} failed: ${result}') + } +} + +fn first_physical_device(instance vk.Instance) !vk.PhysicalDevice { + mut count := u32(0) + require_success(vk.enumerate_physical_devices(instance, &count, unsafe { nil }), + 'enumerate physical device count')! + if count == 0 { + return error('no Vulkan physical device is available') + } + mut devices := unsafe { []vk.PhysicalDevice{len: int(count)} } + require_success(vk.enumerate_physical_devices(instance, &count, devices.data), + 'enumerate physical devices')! + return devices[0] +} + +fn create_upload_buffer(mut allocator vma.Allocator, size u64) !(vk.Buffer, vma.AllocationInfo) { + buffer_info := vk.BufferCreateInfo{ + size: size + usage: u32(vk.BufferUsageFlagBits.transfer_src) + sharingMode: .exclusive + } + mut buffer := vk.Buffer(unsafe { nil }) + mut allocation := vma.AllocationInfo{} + require_success(allocator.create_buffer_with_options(&buffer_info, vma.AllocationOptions{ + usage: .upload + }, &buffer, mut allocation), 'create upload buffer')! + return buffer, allocation +} + +fn run() ! { + require_success(vk.initialize_loader(), 'initialize Vulkan loader')! + application_info := vk.ApplicationInfo{ + pApplicationName: c'vkmemalloc sustained allocation workload' + applicationVersion: 1 + pEngineName: c'none' + apiVersion: vk.api_version_1_1 + } + instance_info := vk.InstanceCreateInfo{ + pApplicationInfo: &application_info + } + mut instance := vk.Instance(unsafe { nil }) + require_success(vk.create_instance(&instance_info, unsafe { nil }, &instance), + 'create Vulkan instance')! + defer { + vk.destroy_instance(instance, unsafe { nil }) + } + vk.load_instance_commands(instance) + + physical_device := first_physical_device(instance)! + mut priority := f32(1) + queue_info := vk.DeviceQueueCreateInfo{ + queueFamilyIndex: 0 + queueCount: 1 + pQueuePriorities: &priority + } + device_info := vk.DeviceCreateInfo{ + queueCreateInfoCount: 1 + pQueueCreateInfos: &queue_info + } + mut device := vk.Device(unsafe { nil }) + require_success(vk.create_device(physical_device, &device_info, unsafe { nil }, &device), + 'create Vulkan device')! + defer { + vk.destroy_device(device, unsafe { nil }) + } + vk.load_device_commands(device) + + mut allocator := vma.new(vma.AllocatorCreateInfo{ + physical_device: physical_device + device: device + preferred_block_size: 4096 + event_trace_capacity: 64 + }) + defer { + allocator.destroy() + } + + for cycle in 0 .. churn_cycles { + mut buffers := []vk.Buffer{len: churn_batch_size, init: vk.Buffer(unsafe { nil })} + mut allocations := []vma.AllocationInfo{len: churn_batch_size} + for index in 0 .. churn_batch_size { + buffer, allocation := create_upload_buffer(mut allocator, u64(64 + + (cycle * 29 + index * 47) % 769))! + buffers[index] = buffer + allocations[index] = allocation + mut mapped := voidptr(unsafe { nil }) + require_success(allocator.map(mut allocations[index], &mapped), 'map upload buffer')! + unsafe { + *(&u8(mapped)) = u8((cycle + index) & 0xff) + } + require_success(allocator.flush_range(allocations[index], 0, 1), 'flush upload buffer')! + allocator.unmap(mut allocations[index]) + } + + // Create holes, refill them with different sizes, then release everything. + for index in 0 .. churn_batch_size { + if index % 2 == 0 { + vk.destroy_buffer(device, buffers[index], unsafe { nil }) + assert allocator.release(mut allocations[index]) + } + } + mut refill_buffers := []vk.Buffer{len: churn_batch_size / 2, init: vk.Buffer(unsafe { nil })} + mut refill_allocations := []vma.AllocationInfo{len: churn_batch_size / 2} + for index in 0 .. refill_buffers.len { + buffer, allocation := create_upload_buffer(mut allocator, u64(96 + + (cycle * 17 + index * 61) % 641))! + refill_buffers[index] = buffer + refill_allocations[index] = allocation + } + for index in 0 .. churn_batch_size { + if index % 2 != 0 { + vk.destroy_buffer(device, buffers[index], unsafe { nil }) + assert allocator.release(mut allocations[index]) + } + } + for index in 0 .. refill_buffers.len { + vk.destroy_buffer(device, refill_buffers[index], unsafe { nil }) + assert allocator.release(mut refill_allocations[index]) + } + assert allocator.stats().allocation_count == 0 + } + + diagnostics := allocator.diagnostics() + expected_allocations := u64(churn_cycles * (churn_batch_size + churn_batch_size / 2)) + assert diagnostics.current.allocation_count == 0 + assert diagnostics.counters.allocation_attempts == expected_allocations + assert diagnostics.counters.allocation_successes == expected_allocations + assert diagnostics.counters.allocation_failures == 0 + assert diagnostics.counters.allocation_releases == expected_allocations + assert diagnostics.counters.block_allocations > 0 + assert diagnostics.counters.block_reuses > 1_000 + assert diagnostics.counters.block_allocations + diagnostics.counters.block_reuses == diagnostics.counters.allocation_successes + assert diagnostics.counters.peak_allocation_count >= churn_batch_size + assert diagnostics.retained_event_count == 64 + assert diagnostics.dropped_event_count > 0 + events := allocator.recent_events() + assert events.len == 64 + assert events[0].sequence < events[events.len - 1].sequence + trimmed := allocator.trim_empty_blocks() + assert trimmed > 0 + assert allocator.diagnostics().counters.trimmed_blocks == u64(trimmed) + assert allocator.stats().block_count == 0 + println('sustained churn passed: ${expected_allocations} allocations, ${diagnostics.counters.block_reuses} block reuses, peak live=${diagnostics.counters.peak_allocation_count}') +} + +fn main() { + run() or { panic(err) } +} diff --git a/v.mod b/v.mod index 2d089b9..fecfbb4 100644 --- a/v.mod +++ b/v.mod @@ -2,7 +2,7 @@ Module { name: 'antono2.vkmemalloc' author: 'Anton Oreskin' description: 'Policy-driven Vulkan memory selection, suballocation, mapping, and diagnostics' - version: '2.4.0' + version: '2.5.0' 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 3923150..39ba398 100644 --- a/vulkan_memory_allocator.v +++ b/vulkan_memory_allocator.v @@ -9,6 +9,7 @@ pub struct Allocator { create_info AllocatorCreateInfo api_version u32 non_coherent_atom_size u64 + event_trace_capacity int mut: props vk.PhysicalDeviceMemoryProperties planner &MemoryBlockPool = unsafe { nil } @@ -20,6 +21,14 @@ mut: memory_budget_reported bool heap_budgets []u64 heap_usages []u64 + counters_ AllocatorCounterState + events []AllocatorEvent + event_cursor int + next_event_sequence u64 = 1 + dropped_event_count u64 + diagnostic_live_count int + diagnostic_live_used u64 + diagnostic_committed u64 } fn (a &Allocator) has_free_slot() bool { @@ -121,8 +130,9 @@ pub mut: // Total size of the VkDeviceMemory block containing this allocation. block_size u64 mut: - reservation BlockReservation - mapped bool + reservation BlockReservation + mapped bool + created_block bool } pub struct MemNode { @@ -143,6 +153,9 @@ pub mut: // Enable VK_EXT_memory_budget property queries. Set this only when the // physical device reports support and the device extension is enabled. memory_budget_enabled bool + // Retain this many recent allocation/release/trim events. Zero (the + // default) disables the trace; cumulative diagnostics remain available. + event_trace_capacity int } // new creates a Vulkan allocator with memory-type-specific shared blocks. @@ -177,6 +190,16 @@ pub fn new(create_info AllocatorCreateInfo) Allocator { u64(1) } planner: planner + event_trace_capacity: if create_info.event_trace_capacity > 0 { + create_info.event_trace_capacity + } else { + 0 + } + events: []AllocatorEvent{cap: if create_info.event_trace_capacity > 0 { + create_info.event_trace_capacity + } else { + 0 + }} } if create_info.memory_budget_enabled { _ = allocator.refresh_memory_budget() @@ -234,7 +257,8 @@ fn (mut a Allocator) allocate_with_policy(mut req vk.MemoryRequirements, type Me // device-local memory after a staging allocation fails is invalid and // previously led to a null mapped pointer and a delayed segfault. eprintln('No compatible Vulkan memory type: type bits 0x${req.memoryTypeBits:08x}, required flags 0x${u32(mem_type):08x}') - return .error_feature_not_present + return a.allocate_from_choices(mut req, [], allocation_pnext, dedicated, .ignore, mut + alloc_info) } choices := ranked_memory_types(a.props, u32(1) << memory_type, req.size, AllocationOptions{ budget_policy: .ignore @@ -256,26 +280,40 @@ fn (mut a Allocator) allocate_from_choices(mut req vk.MemoryRequirements, choice // `alloc_info` is the caller's output record. Reset and populate that record // directly so callers always receive the actual tracked handle. alloc_info = AllocationInfo{} + a.begin_allocation(req.size) if req.size == 0 || req.alignment == 0 { - return .error_initialization_failed + result := vk.Result.error_initialization_failed + a.note_allocation_failure(result, req.size, max_u32, max_u32, dedicated) + return result } if isnil(a.planner) { - return .error_initialization_failed + result := vk.Result.error_initialization_failed + a.note_allocation_failure(result, req.size, max_u32, max_u32, dedicated) + return result } if choices.len == 0 { - return .error_feature_not_present + result := vk.Result.error_feature_not_present + a.note_allocation_failure(result, req.size, max_u32, max_u32, dedicated) + return result } mut last_result := vk.Result.error_out_of_device_memory - for choice in choices { + mut last_memory_type := max_u32 + mut last_heap_index := max_u32 + for choice_index, choice in choices { + last_memory_type = choice.index + last_heap_index = choice.heap_index allow_new_block := budget_policy != .require_within || choice.within_budget + a.note_memory_type_attempt(choice_index > 0) mut result := a.allocate_for_memory_type(mut req, choice, allocation_pnext, dedicated, allow_new_block, budget_policy, mut alloc_info) if result == .success { + a.note_allocation_success(alloc_info, dedicated) return .success } last_result = result if result !in [.error_out_of_device_memory, .error_out_of_host_memory, .error_too_many_objects] { + a.note_allocation_failure(result, req.size, choice.index, choice.heap_index, dedicated) return result } mut trimmed := 0 @@ -288,17 +326,22 @@ fn (mut a Allocator) allocate_from_choices(mut req vk.MemoryRequirements, choice } } if trimmed > 0 { + a.counters_.trim_retry_attempts++ + a.note_memory_type_attempt(choice_index > 0) result = a.allocate_for_memory_type(mut req, choice, allocation_pnext, dedicated, true, budget_policy, mut alloc_info) if result == .success { + a.note_allocation_success(alloc_info, dedicated) return .success } last_result = result } if result == .error_too_many_objects { + a.note_allocation_failure(result, req.size, choice.index, choice.heap_index, dedicated) return result } } + a.note_allocation_failure(last_result, req.size, last_memory_type, last_heap_index, dedicated) return last_result } @@ -310,6 +353,8 @@ fn (mut a Allocator) allocate_for_memory_type(mut req vk.MemoryRequirements, cho return .error_initialization_failed } a.populate_allocation(mut alloc_info, memory, reservation) + alloc_info.created_block = false + a.counters_.block_reuses++ return .success } } @@ -365,6 +410,8 @@ fn (mut a Allocator) allocate_for_memory_type(mut req vk.MemoryRequirements, cho return .error_out_of_device_memory } a.populate_allocation(mut alloc_info, memory, reservation) + alloc_info.created_block = true + a.counters_.block_allocations++ return .success } @@ -730,6 +777,7 @@ pub fn (mut a Allocator) release(mut alloc_info AllocationInfo) bool { if !a.planner.release(alloc_info.reservation) { return false } + released_info := alloc_info if dedicated { memory := a.memory_for_block(block_id) or { return false } if !a.planner.remove_empty_block(block_id) { @@ -737,7 +785,9 @@ pub fn (mut a Allocator) release(mut alloc_info AllocationInfo) bool { } _ = a.forget_block(block_id) or { return false } vk.free_memory(a.create_info.device, memory, unsafe { nil }) + a.counters_.block_frees++ } + a.note_allocation_release(released_info, dedicated) alloc_info = AllocationInfo{} return true } @@ -786,6 +836,15 @@ fn (mut a Allocator) trim_empty_blocks_filtered(heap_index u32, filter_by_heap b index++ continue } + block_size := a.planner.block_capacity(block_id) or { + index++ + continue + } + heap := if memory_type < a.props.memoryTypeCount { + a.props.memoryTypes[memory_type].heapIndex + } else { + max_u32 + } if !a.planner.remove_empty_block(block_id) { index++ continue @@ -797,6 +856,7 @@ fn (mut a Allocator) trim_empty_blocks_filtered(heap_index u32, filter_by_heap b return removed } vk.free_memory(a.create_info.device, memory, unsafe { nil }) + a.note_block_trimmed(memory_type, heap, block_size) removed++ } return removed @@ -865,11 +925,15 @@ pub fn (mut a Allocator) destroy() { a.map_refs[i] = 0 } vk.free_memory(a.create_info.device, a.pools[i], unsafe { nil }) + a.counters_.block_frees++ a.pools[i] = unsafe { nil } a.block_ids[i] = 0 } } a.pool_size = 0 + a.diagnostic_live_count = 0 + a.diagnostic_live_used = 0 + a.diagnostic_committed = 0 if !isnil(a.planner) { a.planner = new_memory_block_pool(a.planner.default_block_size, a.planner.max_blocks) or { unsafe { nil }