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
6 changes: 5 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion compatibility.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"ci_runtime": "v1.2",
"modules": {
"vulkan": "v3.2.2",
"memory": "v1.4.0"
"memory": "v1.5.0"
},
"build_tools": [
"git",
Expand Down
48 changes: 48 additions & 0 deletions docs/deferred-retirement.md
Original file line number Diff line number Diff line change
@@ -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.
207 changes: 207 additions & 0 deletions examples/buffer_suballocation/deferred_uploads.v
Original file line number Diff line number Diff line change
@@ -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, &region)
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')
}
8 changes: 5 additions & 3 deletions examples/buffer_suballocation/main.v
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
}
Expand Down Expand Up @@ -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() {
Expand Down
38 changes: 38 additions & 0 deletions examples/buffer_suballocation/queue_family_test.v
Original file line number Diff line number Diff line change
@@ -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
}
Loading