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: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ jobs:
path: source/modules/antono2/vulkan
- uses: actions/checkout@v7
with:
repository: antono2/memory
ref: v0.2.0
path: source/modules/generic_pool
repository: antono2/mem
ref: v1.0.3
path: source/modules/antono2/mem
- uses: prantlf/setup-v-action@v4
- name: Install Vulkan development library
run: sudo apt-get update && sudo apt-get install -y libvulkan-dev libvulkan-volk-dev mesa-vulkan-drivers
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

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

## 2.2.0 - 2026-09-10

- Migrate the allocation-policy dependency to the canonical `antono2.mem`
module and pin it to the immutable v1.0.3 release.
- Return a local `UploadRingStats` value from `UploadRing.stats()` so the public
Vulkan API does not expose the underlying policy module's type.
- Share one reference-counted Vulkan mapping across mapped suballocations in the
same block, allowing production consumers to keep multiple staging ranges
mapped concurrently.

## 2.1.1 - 2026-09-10

- Pin the general allocation dependency to `memory` v0.2.0 so released
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ enough for examples while avoiding one Vulkan allocation per resource.
v install antono2.vkmemalloc
```

VPM installs the Vulkan bindings and `generic_pool` dependencies automatically.
VPM installs the Vulkan bindings and `antono2.mem` dependencies automatically.

The Vulkan loader, headers, and a working GPU driver must also be installed.

The allocator uses [`generic_pool.RangeAllocator`](https://github.com/antono2/memory)
The allocator uses [`antono2.mem`](https://github.com/antono2/mem),
specifically `mem.RangeAllocator`,
for its dependency-free block suballocation policy. Vulkan handles remain
isolated in this module.

Expand Down Expand Up @@ -129,6 +130,8 @@ Slices are strictly FIFO and never cross the end of the buffer. Retirement is
rejected when attempted out of order. The caller owns submission tracking and
must not retire a slice until the GPU has finished reading it. Call
`uploads.destroy()` before destroying the allocator or Vulkan device.
`uploads.stats()` returns `UploadRingStats`, keeping this module's public API
independent of the internal allocation-policy type.

## Memory classes

Expand All @@ -146,8 +149,8 @@ must not retire a slice until the GPU has finished reading it. Call
contain many suballocations.
- The allocator is not internally synchronized. Externally synchronize access
when multiple threads can allocate or free concurrently.
- Do not map two allocations sharing one memory block concurrently; Vulkan
permits a device-memory object to be mapped only once at a time.
- 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, enforce heap budgets, choose
between equivalent heaps, or automatically flush non-coherent memory.
- Vulkan objects must not outlive the memory bound to them.
Expand Down
8 changes: 4 additions & 4 deletions block_pool.v
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
module vkmemalloc

import generic_pool
import antono2.mem

struct BlockReservation {
owner voidptr
block_id u64
allocation generic_pool.RangeAllocation
allocation mem.RangeAllocation
pub:
memory_type u32
offset u64
Expand All @@ -17,7 +17,7 @@ struct MemoryBlock {
memory_type u32
capacity u64
dedicated bool
ranges &generic_pool.RangeAllocator @[required]
ranges &mem.RangeAllocator @[required]
}

struct BlockPoolStats {
Expand Down Expand Up @@ -106,7 +106,7 @@ fn (mut pool MemoryBlockPool) add_block_with_policy(memory_type u32, capacity u6
memory_type: memory_type
capacity: capacity
dedicated: dedicated
ranges: generic_pool.new_range_allocator(capacity)
ranges: mem.new_range_allocator(capacity)
}
return id
}
Expand Down
13 changes: 10 additions & 3 deletions examples/buffer_suballocation/main.v
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,19 @@ fn run() ! {
assert stats.block_count == 1
assert stats.allocation_count == 2
println('two buffers share one block: committed=${stats.committed}, used=${stats.used}')
mut mapped := voidptr(unsafe { nil })
require_success(allocator.map(mut second_allocation, &mapped), 'map second staging buffer')!
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')!
require_success(allocator.map(mut second_allocation, &second_mapped),
'map second staging buffer')!
unsafe {
*(&u8(mapped)) = 42
*(&u8(first_mapped)) = 21
*(&u8(second_mapped)) = 42
}
assert usize(second_mapped) - usize(first_mapped) == second_allocation.offset - first_allocation.offset
allocator.unmap(mut first_allocation)
allocator.unmap(mut second_allocation)
println('shared staging suballocations mapped concurrently')

image_info := vk.ImageCreateInfo{
imageType: ._2d
Expand Down
40 changes: 32 additions & 8 deletions upload_ring.v
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
module vkmemalloc

import generic_pool
import antono2.mem
import antono2.vulkan as vk

// UploadSlice identifies one persistently mapped staging-buffer range. Slices
// must be retired in allocation order after the GPU no longer reads them.
pub struct UploadSlice {
owner voidptr
allocation generic_pool.RingAllocation
allocation mem.RingAllocation
pub:
offset u64
size u64
data voidptr
}

// UploadRingStats describes current payload, alignment/wrap padding, free
// space, and peak occupancy without exposing the underlying policy type.
pub struct UploadRingStats {
pub:
capacity u64
used u64
payload u64
padding u64
free u64
peak_used u64
allocation_count int
largest_contiguous_free u64
}

// UploadRing owns one dedicated, persistently mapped Vulkan staging buffer and
// suballocates it with FIFO ring semantics. It is not internally synchronized.
pub struct UploadRing {
Expand All @@ -23,8 +37,8 @@ pub:
mut:
allocator &Allocator = unsafe { nil }
backing AllocationInfo
mapped voidptr = unsafe { nil }
ranges &generic_pool.RingAllocator = unsafe { nil }
mapped voidptr = unsafe { nil }
ranges &mem.RingAllocator = unsafe { nil }
destroyed bool
}

Expand Down Expand Up @@ -63,7 +77,7 @@ pub fn new_upload_ring(mut allocator Allocator, capacity u64) !&UploadRing {
allocator: allocator
backing: backing
mapped: mapped
ranges: generic_pool.new_ring_allocator(capacity)
ranges: mem.new_ring_allocator(capacity)
}
}

Expand Down Expand Up @@ -104,11 +118,21 @@ pub fn (mut ring UploadRing) retire(slice UploadSlice) bool {
}

// stats returns current payload, padding, free-space, and peak ring occupancy.
pub fn (ring &UploadRing) stats() generic_pool.RingStats {
pub fn (ring &UploadRing) stats() UploadRingStats {
if ring.destroyed {
return generic_pool.RingStats{}
return UploadRingStats{}
}
stats := ring.ranges.stats()
return UploadRingStats{
capacity: stats.capacity
used: stats.used
payload: stats.payload
padding: stats.padding
free: stats.free
peak_used: stats.peak_used
allocation_count: stats.allocation_count
largest_contiguous_free: stats.largest_contiguous_free
}
return ring.ranges.stats()
}

// destroy invalidates all slices, unmaps and destroys the buffer, and releases
Expand Down
8 changes: 4 additions & 4 deletions upload_ring_test.v
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
module vkmemalloc

import generic_pool
import antono2.mem

fn test_upload_ring_returns_aligned_host_pointers_and_wraps() {
mut storage := []u8{len: 64}
mut uploads := UploadRing{
capacity: 64
mapped: storage.data
ranges: generic_pool.new_ring_allocator(64)
ranges: mem.new_ring_allocator(64)
}
first := uploads.allocate(24, 16) or { panic(err) }
second := uploads.allocate(24, 16) or { panic(err) }
Expand Down Expand Up @@ -37,12 +37,12 @@ fn test_upload_ring_rejects_foreign_forged_and_destroyed_slices() {
mut first_ring := UploadRing{
capacity: 32
mapped: first_storage.data
ranges: generic_pool.new_ring_allocator(32)
ranges: mem.new_ring_allocator(32)
}
mut second_ring := UploadRing{
capacity: 32
mapped: second_storage.data
ranges: generic_pool.new_ring_allocator(32)
ranges: mem.new_ring_allocator(32)
}
allocation := first_ring.allocate(8, 1) or { panic(err) }
foreign := second_ring.allocate(8, 1) or { panic(err) }
Expand Down
4 changes: 2 additions & 2 deletions v.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ Module {
name: 'antono2.vkmemalloc'
author: 'Anton Oreskin'
description: 'Vulkan block suballocation helpers for buffers and images'
version: '2.1.1'
version: '2.2.0'
license: 'MIT'
repo_url: 'https://github.com/antono2/vulkan_memory_allocator'
tags: ['V','vulkan','allocator']
dependencies: ['antono2.vulkan','https://github.com/antono2/memory@v0.2.0']
dependencies: ['antono2.vulkan','antono2.mem@v1.0.3']
}
71 changes: 65 additions & 6 deletions vulkan_memory_allocator.v
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ mut:
planner &MemoryBlockPool = unsafe { nil }
pools [max_pools]vk.DeviceMemory
block_ids [max_pools]u64
mapped [max_pools]voidptr
map_refs [max_pools]u32
pool_size u32
}

Expand All @@ -36,6 +38,8 @@ fn (mut a Allocator) remember_block(memory vk.DeviceMemory, block_id u64) bool {
if isnil(a.pools[i]) {
a.pools[i] = memory
a.block_ids[i] = block_id
a.mapped[i] = unsafe { nil }
a.map_refs[i] = 0
return true
}
}
Expand All @@ -44,6 +48,8 @@ fn (mut a Allocator) remember_block(memory vk.DeviceMemory, block_id u64) bool {
}
a.pools[a.pool_size] = memory
a.block_ids[a.pool_size] = block_id
a.mapped[a.pool_size] = unsafe { nil }
a.map_refs[a.pool_size] = 0
a.pool_size++
return true
}
Expand All @@ -54,6 +60,8 @@ fn (mut a Allocator) forget_block(block_id u64) ?vk.DeviceMemory {
memory := a.pools[i]
a.pools[i] = unsafe { nil }
a.block_ids[i] = 0
a.mapped[i] = unsafe { nil }
a.map_refs[i] = 0
for a.pool_size > 0 && isnil(a.pools[a.pool_size - 1]) {
a.pool_size--
}
Expand All @@ -63,6 +71,15 @@ fn (mut a Allocator) forget_block(block_id u64) ?vk.DeviceMemory {
return none
}

fn (a &Allocator) block_index(block_id u64) ?int {
for i in 0 .. a.pool_size {
if a.block_ids[i] == block_id && !isnil(a.pools[i]) {
return int(i)
}
}
return none
}

fn (a &Allocator) memory_for_block(block_id u64) ?vk.DeviceMemory {
for i in 0 .. a.pool_size {
if a.block_ids[i] == block_id && !isnil(a.pools[i]) {
Expand Down Expand Up @@ -95,6 +112,7 @@ pub mut:
size u64
mut:
reservation BlockReservation
mapped bool
}

pub struct MemNode {
Expand Down Expand Up @@ -418,20 +436,53 @@ pub fn (mut a Allocator) create_image(p_image_create_info &vk.ImageCreateInfo, t
return vk.Result.success
}

// map maps the allocation's byte range for host access.
// map maps the allocation's byte range for host access. Compatible allocations
// sharing one VkDeviceMemory block share one Vulkan mapping internally.
pub fn (mut a Allocator) map(mut alloc_info AllocationInfo, data &voidptr) vk.Result {
if !a.owns_allocation(alloc_info) {
eprintln('Cannot map an allocation not owned by this allocator')
return .error_memory_map_failed
}
if alloc_info.mapped {
eprintln('Cannot map an allocation that is already mapped')
return .error_memory_map_failed
}
index := a.block_index(alloc_info.reservation.block_id) or {
eprintln('Cannot map an allocation whose memory block is unavailable')
return .error_memory_map_failed
}
return vk.map_memory(a.create_info.device, alloc_info.memory, alloc_info.offset,
alloc_info.size, 0, data)
if isnil(a.mapped[index]) {
mut base := voidptr(unsafe { nil })
result := vk.map_memory(a.create_info.device, alloc_info.memory, 0, vk.whole_size, 0, &base)
if result != .success {
eprintln('Could not map Vulkan memory block ${alloc_info.reservation.block_id}: ${result}')
return result
}
a.mapped[index] = base
}
unsafe {
*data = voidptr(usize(a.mapped[index]) + usize(alloc_info.offset))
}
a.map_refs[index]++
alloc_info.mapped = true
return .success
}

// unmap unmaps the memory block containing an allocation.
// unmap releases this allocation's mapping reference. The Vulkan memory block
// remains mapped until every mapped suballocation has been unmapped.
pub fn (mut a Allocator) unmap(mut alloc_info AllocationInfo) {
if a.owns_allocation(alloc_info) {
vk.unmap_memory(a.create_info.device, alloc_info.memory)
if !a.owns_allocation(alloc_info) || !alloc_info.mapped {
return
}
index := a.block_index(alloc_info.reservation.block_id) or { return }
if a.map_refs[index] > 0 {
a.map_refs[index]--
}
if a.map_refs[index] == 0 && !isnil(a.mapped[index]) {
vk.unmap_memory(a.create_info.device, a.pools[index])
a.mapped[index] = unsafe { nil }
}
alloc_info.mapped = false
}

// release returns a tracked suballocation to its VkDeviceMemory block. Empty
Expand All @@ -440,6 +491,9 @@ pub fn (mut a Allocator) release(mut alloc_info AllocationInfo) bool {
if !a.owns_allocation(alloc_info) {
return false
}
if alloc_info.mapped {
a.unmap(mut alloc_info)
}
block_id := alloc_info.reservation.block_id
dedicated := a.planner.block_is_dedicated(block_id) or { return false }
if !a.planner.release(alloc_info.reservation) {
Expand Down Expand Up @@ -529,6 +583,11 @@ pub fn (a &Allocator) stats() AllocatorStats {
pub fn (mut a Allocator) destroy() {
for i in 0 .. a.pool_size {
if !isnil(a.pools[i]) {
if !isnil(a.mapped[i]) {
vk.unmap_memory(a.create_info.device, a.pools[i])
a.mapped[i] = unsafe { nil }
a.map_refs[i] = 0
}
vk.free_memory(a.create_info.device, a.pools[i], unsafe { nil })
a.pools[i] = unsafe { nil }
a.block_ids[i] = 0
Expand Down