diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2bfc55..ff4ba7d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -142,7 +142,7 @@ jobs: - name: Verify the public setup diagnostics working-directory: source/modules/antono2/vkmemalloc run: v run setup.vsh --check - - name: Test with the Microsoft C compiler + - name: Test and run fenced uploads with the Microsoft C compiler working-directory: source/modules/antono2/vkmemalloc shell: pwsh run: | @@ -152,6 +152,10 @@ jobs: Import-Module (Join-Path $InstallPath 'Common7\Tools\Microsoft.VisualStudio.DevShell.dll') Enter-VsDevShell -VsInstallPath $InstallPath -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -host_arch=x64' v -cc msvc test . + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:VK_INSTANCE_LAYERS = 'VK_LAYER_KHRONOS_validation' + v -cc msvc run examples/buffer_suballocation + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } linux-v-master: name: Advisory current V master and Vulkan master diff --git a/CHANGELOG.md b/CHANGELOG.md index e72fc2f..3184ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## 2.7.0 - 2026-10-14 + +- Pin `antono2.memory` v1.5.0 and extend the buffer example with bounded, + completion-driven upload retirement. +- Submit real buffer copies, observe fences before reuse, exercise upload-ring + wraparound and backpressure, and verify independent host readback. +- Keep the existing `UploadRing` API and caller-owned synchronization contract. +- Document completion domains, cleanup ordering and portable reproduction steps. +- Make Vulkan descriptor locals compatible with current V pointer checking. +- Discover a transfer-capable queue and run fenced upload checks on Linux with + Lavapipe and Windows with MSVC/SwiftShader and Vulkan validation enabled. + ## 2.6.0 - 2026-09-12 - Add explicit `create_suballocated_image()` and diff --git a/README.md b/README.md index 2d74c5e..c48a046 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,17 @@ replace an installed SDK. CI runs the complete allocator test suite on Windows Server 2022 with MSVC and SwiftShader. Linux CI additionally runs the real Vulkan buffer, image and sustained-allocation example workloads. -The allocator uses the production-hardened v1.4 release of +The allocator uses the v1.5 release of [`antono2.memory`](https://github.com/antono2/memory), specifically `memory.RangeAllocator`, for its dependency-free block suballocation policy. Vulkan handles remain isolated in this module. +The buffer-suballocation example also demonstrates +[fenced upload retirement](docs/deferred-retirement.md) using +`memory.RetirementQueue`. It observes real Vulkan fence completion, preserves +FIFO slice release and verifies copied data through mapped readback. + ## Basic use Create one allocator after selecting a physical device and creating its logical diff --git a/compatibility.json b/compatibility.json index fa726e4..4b16e92 100644 --- a/compatibility.json +++ b/compatibility.json @@ -5,7 +5,7 @@ "ci_runtime": "v1.2", "modules": { "vulkan": "v3.2.2", - "memory": "v1.4.0" + "memory": "v1.5.0" }, "build_tools": [ "git", diff --git a/docs/deferred-retirement.md b/docs/deferred-retirement.md new file mode 100644 index 0000000..f88980a --- /dev/null +++ b/docs/deferred-retirement.md @@ -0,0 +1,48 @@ +# Fenced upload retirement + +This example uses `antono2.memory@v1.5.0` and the Vulkan dependency pinned in +`v.mod` and `compatibility.json`. Install the declared dependencies with the +repository setup script. If using an isolated module directory, set `VMODULES` +to that directory before setup and validation. + +`examples/buffer_suballocation/deferred_uploads.v` uses the existing example's +device and allocator. It registers upload slices in a two-entry retirement queue, +submits real buffer copies, observes Vulkan fences before reporting completion, +and releases slices in allocation order. A rejected full-queue registration +leaves upload accounting unchanged. After observing the first fence, another +allocation wraps to offset zero. A final fence and transfer-to-host barriers +permit independent mapped readback of values `21,22,23` before shutdown. + +The example performs no premature reuse even if the GPU finishes before the +application polls. Cleanup waits for actual device completion before draining +and destroying pending resources. Device-loss recovery was not fault-injected. +The example discovers an available transfer-capable queue before creating the +device. Graphics and compute queues qualify through their implicit transfer +support. The helper receives the created queue family index. + +## Verified locally + +- Official released V 0.5.2 (`7647ce1`), Lavapipe, validation layer enabled: pass. +- Official released V 0.5.2, NVIDIA GeForce GTX 1060, validation layer enabled: pass. +- Pinned V `efae23e` with explicit V3, Lavapipe, validation layer enabled: pass. +- Output: `fenced retirement: copies=3 readback=21,22,23 wrap=0 pending=0`. +- No validation-layer errors were reported by these runs. + +The two existing instance/device descriptor locals are mutable for compatibility +with the pinned V3 compiler's pointer checking. No public UploadRing API changes +are required. These local results are Linux device evidence. CI separately runs +the fenced-copy program on native Windows with MSVC, SwiftShader and validation +enabled. No native macOS runtime coverage is claimed. + +Run from the repository root with the native Vulkan prerequisites installed: + +```sh +compiler=${VEXE:-v} +"$compiler" run setup.vsh +VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation \ + "$compiler" run examples/buffer_suballocation +``` + +Use the pinned current compiler and add `-new-compiler` for the strict V3 run. +To select a specific driver, set `VK_DRIVER_FILES` to an installed ICD manifest +on the current host. Otherwise the Vulkan loader selects the available driver. diff --git a/examples/buffer_suballocation/deferred_uploads.v b/examples/buffer_suballocation/deferred_uploads.v new file mode 100644 index 0000000..9f0fa0a --- /dev/null +++ b/examples/buffer_suballocation/deferred_uploads.v @@ -0,0 +1,207 @@ +// Uses memory v1.5's bounded completion queue with actual Vulkan fences. +// Exercises real ordered queue submissions, fence observation and host readback. +module main + +import antono2.memory +import antono2.vkmemalloc as vma +import antono2.vulkan as vk + +// Graphics and compute queues implicitly support transfer operations even if +// the driver omits the explicit transfer flag. Ignore unavailable queues. +fn select_transfer_family(properties []vk.QueueFamilyProperties) ?u32 { + capabilities := u32(vk.QueueFlagBits.graphics) | u32(vk.QueueFlagBits.compute) | u32(vk.QueueFlagBits.transfer) + for index, property in properties { + if property.queueCount > 0 && property.queueFlags & capabilities != 0 { + return u32(index) + } + } + return none +} + +fn transfer_queue_family(physical_device vk.PhysicalDevice) !u32 { + mut count := u32(0) + mut no_properties := unsafe { nil } + vk.get_physical_device_queue_family_properties(physical_device, &count, mut no_properties) + if count == 0 { return error('physical device has no queue families') } + mut properties := []vk.QueueFamilyProperties{len: int(count)} + vk.get_physical_device_queue_family_properties(physical_device, &count, mut properties[0]) + return select_transfer_family(properties[..int(count)]) or { + return error('physical device has no transfer-capable queue family') + } +} + +fn record_upload(command vk.CommandBuffer, source vk.Buffer, destination vk.Buffer, slice vma.UploadSlice, offset u64) ! { + begin := vk.CommandBufferBeginInfo{ + flags: u32(vk.CommandBufferUsageFlagBits.one_time_submit) + } + require_success(vk.begin_command_buffer(command, &begin), 'begin deferred copy')! + region := vk.BufferCopy{ + srcOffset: slice.offset + dstOffset: offset + size: 4 + } + vk.cmd_copy_buffer(command, source, destination, 1, ®ion) + barrier := vk.BufferMemoryBarrier{ + srcAccessMask: u32(vk.AccessFlagBits.transfer_write) + dstAccessMask: u32(vk.AccessFlagBits.host_read) + srcQueueFamilyIndex: vk.queue_family_ignored + dstQueueFamilyIndex: vk.queue_family_ignored + buffer: destination + offset: offset + size: 4 + } + vk.cmd_pipeline_barrier(command, u32(vk.PipelineStageFlagBits.transfer), + u32(vk.PipelineStageFlagBits.host), 0, 0, unsafe { nil }, 1, &barrier, 0, unsafe { nil }) + require_success(vk.end_command_buffer(command), 'end deferred copy')! +} + +fn submit_copy(queue vk.Queue, command vk.CommandBuffer, fence vk.Fence) ! { + submit := vk.SubmitInfo{ + commandBufferCount: 1 + pCommandBuffers: &command + } + require_success(vk.queue_submit(queue, 1, &submit, fence), 'submit deferred copy')! +} + +fn retire_completed(mut pending memory.RetirementQueue[vma.UploadSlice], mut uploads vma.UploadRing) int { + mut retired := 0 + for { + slice := pending.try_take_ready() or { break } + if !uploads.retire(slice) { panic('upload FIFO retirement failed') } + retired++ + } + return retired +} + +fn run_deferred_uploads(device vk.Device, family u32, mut allocator vma.Allocator) ! { + mut queue := vk.Queue(unsafe { nil }) + vk.get_device_queue(device, family, 0, &queue) + pool_info := vk.CommandPoolCreateInfo{ + queueFamilyIndex: family + } + mut command_pool := vk.CommandPool(unsafe { nil }) + require_success(vk.create_command_pool(device, &pool_info, unsafe { nil }, &command_pool), + 'create copy pool')! + defer { vk.destroy_command_pool(device, command_pool, unsafe { nil }) } + mut commands := unsafe { []vk.CommandBuffer{len: 3} } + allocate_info := vk.CommandBufferAllocateInfo{ + commandPool: command_pool + level: .primary + commandBufferCount: 3 + } + require_success(vk.allocate_command_buffers(device, &allocate_info, commands.data), + 'allocate copy commands')! + mut fences := []vk.Fence{len: 3, init: vk.Fence(unsafe { nil })} + defer { + for fence in fences { + if !isnil(fence) { vk.destroy_fence(device, fence, unsafe { nil }) } + } + } + for index in 0 .. fences.len { + info := vk.FenceCreateInfo{} + mut fence := vk.Fence(unsafe { nil }) + require_success(vk.create_fence(device, &info, unsafe { nil }, &fence), 'create copy fence')! + fences[index] = fence + } + buffer_info := vk.BufferCreateInfo{ + size: 16 + usage: u32(vk.BufferUsageFlagBits.transfer_dst) + sharingMode: .exclusive + } + mut destination := vk.Buffer(unsafe { nil }) + mut destination_allocation := vma.AllocationInfo{} + require_success(allocator.create_buffer_with_options(&buffer_info, vma.AllocationOptions{ + usage: .readback + }, &destination, mut destination_allocation), 'create readback destination')! + defer { + vk.destroy_buffer(device, destination, unsafe { nil }) + _ = allocator.release(mut destination_allocation) + } + mut uploads := vma.new_upload_ring_with_options(mut allocator, 1024, vma.AllocationOptions{ + usage: .upload + })! + mut pending := memory.new_retirement_queue[vma.UploadSlice](2)! + defer { + // This runs before buffer/fence/command cleanup, including on error paths. + idle := vk.device_wait_idle(device) + if idle == .success { + pending.advance_completed(~u64(0)) or { panic(err) } + _ = retire_completed(mut pending, mut uploads) + _ = uploads.destroy() + } else { + // A lost device is not completion evidence. Destroy the API object as + // part of device teardown without falsely reporting completed tokens. + vk.destroy_buffer(device, uploads.buffer, unsafe { nil }) + } + } + for index in 0 .. 2 { + if pending.is_full() { + return error('unexpected queue backpressure') + } + slice := uploads.allocate(400, 16)! + pending.enqueue(slice, u64(index + 1))! + unsafe { + *(&u32(slice.data)) = u32(21 + index) + } + require_success(uploads.flush(slice), 'flush queued upload')! + record_upload(commands[index], uploads.buffer, destination, slice, u64(index * 4))! + submit_copy(queue, commands[index], fences[index])! + } + // No reuse before the application observes completion, even if the GPU has + // already finished. A full queue must not cause another ring allocation. + if pending.try_take_ready() != none { + return error('premature retirement') + } + if !pending.is_full() { + return error('expected bounded queue exhaustion') + } + before := uploads.stats() + if _ := pending.enqueue(vma.UploadSlice{}, 3) { + return error('expected backpressure') + } + if uploads.stats() != before { + return error('backpressure changed upload allocation state') + } + first_fence := fences[0] + require_success(vk.wait_for_fences(device, 1, &first_fence, vk._true, 10_000_000_000), + 'wait first copy')! + pending.advance_completed(1)! + if retire_completed(mut pending, mut uploads) != 1 { + return error('expected first retirement only') + } + wrapped := uploads.allocate(300, 16)! + pending.enqueue(wrapped, 3)! + if wrapped.offset != 0 { + return error('expected upload wraparound') + } + unsafe { + *(&u32(wrapped.data)) = 23 + } + require_success(uploads.flush(wrapped), 'flush wrapped queued upload')! + record_upload(commands[2], uploads.buffer, destination, wrapped, 8)! + submit_copy(queue, commands[2], fences[2])! + // One queue executes these submissions in order. The final fence covers all + // three copies and their transfer-to-host barriers; unrelated queues would + // require separate completion domains or explicit synchronization. + last_fence := fences[2] + require_success(vk.wait_for_fences(device, 1, &last_fence, vk._true, 10_000_000_000), + 'wait final copy')! + pending.advance_completed(3)! + if retire_completed(mut pending, mut uploads) != 2 { + return error('expected final two retirements') + } + mut mapped := voidptr(unsafe { nil }) + require_success(allocator.map(mut destination_allocation, &mapped), 'map copy readback')! + defer { allocator.unmap(mut destination_allocation) } + require_success(allocator.invalidate(destination_allocation), 'invalidate copy readback')! + for index in 0 .. 3 { + actual := unsafe { *(&u32(usize(mapped) + usize(index * 4))) } + if actual != u32(21 + index) { + return error('independent copy readback mismatch at ${index}: ${actual}') + } + } + if !pending.is_empty() || uploads.stats().allocation_count != 0 { + return error('pending work at shutdown') + } + println('fenced retirement: copies=3 readback=21,22,23 wrap=0 pending=0') +} diff --git a/examples/buffer_suballocation/main.v b/examples/buffer_suballocation/main.v index d917369..c85f67a 100644 --- a/examples/buffer_suballocation/main.v +++ b/examples/buffer_suballocation/main.v @@ -26,7 +26,7 @@ fn first_physical_device(instance vk.Instance) !vk.PhysicalDevice { fn run() ! { require_success(vk.initialize_loader(), 'initialize Vulkan loader')! - application_info := vk.ApplicationInfo{ + mut application_info := vk.ApplicationInfo{ pApplicationName: c'vkmemalloc suballocation example' applicationVersion: 1 pEngineName: c'none' @@ -44,14 +44,15 @@ fn run() ! { vk.load_instance_commands(instance) physical_device := first_physical_device(instance)! + transfer_family := transfer_queue_family(physical_device)! memory_budget_supported := vma.supports_memory_budget(physical_device) mut device_extensions := []&char{} if memory_budget_supported { device_extensions << vk.ext_memory_budget_extension_name } mut priority := f32(1) - queue_info := vk.DeviceQueueCreateInfo{ - queueFamilyIndex: 0 + mut queue_info := vk.DeviceQueueCreateInfo{ + queueFamilyIndex: transfer_family queueCount: 1 pQueuePriorities: &priority } @@ -242,6 +243,7 @@ fn run() ! { uploads_destroyed := uploads.destroy() assert uploads_destroyed assert allocator.stats().block_count == 0 + run_deferred_uploads(device, transfer_family, mut allocator)! } fn main() { diff --git a/examples/buffer_suballocation/queue_family_test.v b/examples/buffer_suballocation/queue_family_test.v new file mode 100644 index 0000000..3e5c7b8 --- /dev/null +++ b/examples/buffer_suballocation/queue_family_test.v @@ -0,0 +1,38 @@ +// Checks unavailable and non-transfer families before device creation. +module main + +import antono2.vulkan as vk + +fn test_transfer_family_selection_rejects_unavailable_and_sparse_only_queues() { + if _ := select_transfer_family([]vk.QueueFamilyProperties{}) { assert false + } + if _ := select_transfer_family([ + vk.QueueFamilyProperties{ queueCount: 0, queueFlags: u32(vk.QueueFlagBits.transfer) }, + vk.QueueFamilyProperties{ queueCount: 1, queueFlags: u32(vk.QueueFlagBits.sparse_binding) }, + ]) + { + assert false + } + families := [ + vk.QueueFamilyProperties{ + queueCount: 1 + queueFlags: u32(vk.QueueFlagBits.sparse_binding) + }, + vk.QueueFamilyProperties{ + queueCount: 0 + queueFlags: u32(vk.QueueFlagBits.graphics) + }, + vk.QueueFamilyProperties{ + queueCount: 1 + queueFlags: u32(vk.QueueFlagBits.compute) + }, + vk.QueueFamilyProperties{ + queueCount: 1 + queueFlags: u32(vk.QueueFlagBits.transfer) + }, + ] + assert (select_transfer_family(families) or { panic('missing compute transfer support') }) == 2 + assert (select_transfer_family([ + vk.QueueFamilyProperties{ queueCount: 1, queueFlags: u32(vk.QueueFlagBits.graphics) }, + ]) or { panic('missing graphics transfer support') }) == 0 +} diff --git a/v.mod b/v.mod index 8784f39..9def3a4 100644 --- a/v.mod +++ b/v.mod @@ -2,9 +2,9 @@ Module { name: 'antono2.vkmemalloc' author: 'Anton Oreskin' description: 'Policy-driven Vulkan memory selection, suballocation, mapping, and diagnostics' - version: '2.6.2' + version: '2.7.0' license: 'MIT' repo_url: 'https://github.com/antono2/vulkan_memory_allocator' tags: ['V','vulkan','allocator'] - dependencies: ['antono2.vulkan@v3.2.2','antono2.memory@v1.4.0'] + dependencies: ['antono2.vulkan@v3.2.2','antono2.memory@v1.5.0'] }