This module provides small, explicit helpers for selecting Vulkan memory types,
allocating and binding memory for buffers and images, mapping host-visible
allocations, and suballocating shared VkDeviceMemory blocks.
Despite the repository name, this is not a binding to AMD's Vulkan Memory Allocator. It is a compact V-native allocator intended to remain understandable enough for examples while avoiding one Vulkan allocation per resource.
The allocator has four deliberately separate layers:
- Policy filters the memory types allowed by Vulkan, applies required
property flags, and ranks the remaining types for GPU-only, upload, or
readback use. The selected
MemoryTypeChoiceexplains the heap, flags, budget state, and score. - Block planning uses
antono2.memory.RangeAllocatorto place compatible resources into larger blocks. Every block has both a Vulkan memory type and a resource class (buffer,linear_image, oroptimal_image), so classes with abufferImageGranularityboundary rule never become adjacent. This CPU-only layer is deterministic and independently tested. - Vulkan ownership creates, maps, binds, and frees
VkDeviceMemorywhileAllocationInfokeeps the selected type, heap, properties, block size, and private ownership record together. - 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.
The default image APIs remain dedicated for compatibility. Explicit
create_suballocated_image* APIs safely share ordinary linear- or
optimal-tiling images within their own class and honor driver requests for
dedicated memory. Existing MemType APIs remain available for short examples;
new applications should normally use AllocationOptions.
v install antono2.vkmemallocVPM installs the Vulkan bindings and antono2.memory dependencies automatically.
The Vulkan loader, headers, and a working GPU driver must also be installed.
For a fresh machine, install the native Vulkan prerequisites, V dependencies, and run the compile checks with one command:
v run setup.vshUse v run setup.vsh --check for a read-only diagnostic pass.
The allocator uses the production-hardened v1.4 release of
antono2.memory, specifically
memory.RangeAllocator,
for its dependency-free block suballocation policy. Vulkan handles remain
isolated in this module.
Create one allocator after selecting a physical device and creating its logical device:
import antono2.vulkan as vk
import antono2.vkmemalloc as vma
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
})Shared blocks are separated by Vulkan memory-type index and resource class.
Small compatible resources share the preferred block size; a resource larger
than that receives a large-enough block of its own. The allocator queries
Vulkan 1.1 dedicated-allocation metadata before suballocating an image and
falls back to dedicated memory on Vulkan 1.0. max_memory_blocks defaults to
256 and may be lowered in the create information.
The lower-level allocate() method also uses an isolated block because raw
VkMemoryRequirements do not identify whether the caller will bind a buffer or
image. Use create_buffer() when automatic buffer suballocation is desired.
The policy API expresses how the resource will be used and keeps hard requirements distinct from preferences:
options := vma.AllocationOptions{
usage: .upload
// These are optional refinements. Required flags are never dropped.
preferred_flags: vk.MemoryPropertyFlags(vk.MemoryPropertyFlagBits.host_cached)
}
result := allocator.create_buffer_with_options(&buffer_info, options, &buffer,
mut allocation).gpu_onlyrequires device-local memory..uploadrequires host-visible memory and prefers coherent, device-local types..readbackrequires host-visible memory and prefers cached, coherent types..automatichas no implicit hard requirement and prefers device-local memory.
required_flags is a hard filter. preferred_flags improves a candidate's
rank, while avoided_flags lowers it without making the type unusable. The
default .prefer_within budget policy moves a heap with enough estimated room
ahead of an otherwise better match. .require_within filters over-budget
heaps for new Vulkan blocks while still permitting reuse of compatible blocks
that are already committed. .ignore ranks without considering room.
Use allocator.select_memory_type(...) when you need to inspect the choice
before creating a resource. Policy allocation tries compatible types in rank
order after reclaiming empty cached blocks on memory pressure. It never relaxes
required flags.
Allocate and bind a buffer, checking the returned Vulkan result:
mut buffer := vk.Buffer(unsafe { nil })
mut allocation := vma.AllocationInfo{}
result := allocator.create_buffer(&buffer_info, .staging, &buffer, mut allocation)
if result != .success {
return error('could not create buffer: ${result}')
}For host-visible staging memory, map and unmap it as follows:
mut mapped := voidptr(unsafe { nil })
if allocator.map(mut allocation, &mapped) != .success {
return error('could not map buffer memory')
}
// Copy data to mapped here.
if allocator.flush(allocation) != .success {
return error('could not flush buffer memory')
}
allocator.unmap(mut allocation)For host-coherent memory, flush() and invalidate() are checked no-ops. For
non-coherent memory they call Vulkan with ranges expanded to
nonCoherentAtomSize. The _range variants accept allocation-relative offsets
and sizes. Flush after host writes before device access; invalidate only after
device writes have completed and before reading them on the host.
Destroy the Vulkan buffer or image before freeing its memory:
vk.destroy_buffer(device, buffer, unsafe { nil })
if !allocator.release(mut allocation) {
return error('allocation was not owned by this allocator')
}release() returns shared ranges to their compatible block so future resources
of the same class and memory type can reuse them. Dedicated allocations are
freed immediately. Empty shared blocks remain cached; reclaim them explicitly
when appropriate:
println('released ${allocator.trim_empty_blocks()} empty memory blocks')Call allocator.destroy() only after destroying every buffer and image backed
by it. This frees any allocations that were not individually released.
create_image() and create_image_with_options() keep every image in a
dedicated block. This remains the simplest default for low image counts and is
source-compatible with earlier releases.
When an application creates many ordinary images, opt into class-safe sharing:
mut image := vk.Image(unsafe { nil })
mut image_allocation := vma.AllocationInfo{}
result := allocator.create_suballocated_image_with_options(&image_info,
vma.AllocationOptions{
usage: .gpu_only
}, &image, mut image_allocation)
if result != .success {
return error('could not create image: ${result}')
}Linear images share only with linear images; optimal images share only with
optimal images; buffers share only with buffers. If Vulkan 1.1's
VkMemoryDedicatedRequirements
query reports that dedicated memory is required or preferred, the allocator
honors it transparently. Vulkan 1.0 also uses the dedicated fallback because it
cannot make the core requirements query.
Sparse images use sparse binding instead of vkBindImageMemory; disjoint images
bind planes separately; DRM format modifier images require additional layout
handling. The explicit suballocation APIs return error_feature_not_present
for these specialized paths rather than treating them as ordinary images.
stats := allocator.stats()
println('blocks: ${stats.block_count}')
println('allocations: ${stats.allocation_count}')
println('committed: ${stats.committed}, used: ${stats.used}, free: ${stats.free}')
println('largest free range: ${stats.largest_free_range}')committed is memory obtained through vkAllocateMemory; used is the sum of
live resource ranges. free_range_count, largest_free_range, and
empty_block_count make cached capacity and external fragmentation visible.
The largest range is measured before applying the alignment of a future
request, so it is diagnostic rather than a guarantee that an allocation will
succeed.
Global free space can also belong to an incompatible Vulkan memory type. When diagnosing a failed request, inspect the type selected for a comparable allocation:
type_stats := allocator.stats_for_memory_type(allocation.mem_type)
println('type ${allocation.mem_type}: free=${type_stats.free}, largest=${type_stats.largest_free_range}')Memory-type totals can still span incompatible resource classes. For the exact set of blocks a similar request could reuse, include the class:
compatible := allocator.stats_for_memory_type_and_class(allocation.mem_type,
allocation.resource_class)
println('compatible free=${compatible.free}, largest=${compatible.largest_free_range}')stats_for_resource_class() aggregates one class across memory types.
If total compatible free space is large enough but its largest range is too
small, the existing blocks are externally fragmented. If an empty block is
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.
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:
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.
for event in allocator.recent_events() {
println('#${event.sequence} ${event.kind}: size=${event.requested_size}, type=${event.memory_type}, class=${event.resource_class}, 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.
VK_EXT_memory_budget exposes driver estimates for current heap usage and the
amount the process can reasonably consume. The current integration uses Vulkan
1.1's properties query. Opt in only after confirming and enabling the device
extension:
budget_supported := vma.supports_memory_budget(physical_device)
// Add vk.ext_memory_budget_extension_name to VkDeviceCreateInfo when true.
mut allocator := vma.new(vma.AllocatorCreateInfo{
physical_device: physical_device
device: device
memory_budget_enabled: budget_supported
})
_ = allocator.refresh_memory_budget()
for heap in allocator.memory_heaps() {
println('heap ${heap.heap_index}: ${heap.usage}/${heap.budget}')
}The runnable example shows the complete extension-name array and logical-device creation sequence.
new() obtains an initial enabled budget snapshot. Call
refresh_memory_budget() periodically (for example, once per frame or every
few seconds); policy selection uses the latest snapshot without adding a driver
query to every allocation. Without the extension, the same APIs fall back to
physical heap sizes and this allocator's own committed blocks. Budgets are
changing estimates, not reservations; Vulkan allocation can still fail and the
returned vk.Result remains authoritative.
UploadRing owns one dedicated, persistently mapped, host-coherent staging
buffer. Each allocation returns both a writable host pointer and the matching
buffer-relative offset for a transfer command:
mut uploads := vma.new_upload_ring(mut allocator, 16 * 1024 * 1024) or {
panic(err)
}
slice := uploads.allocate(4096, 256) or { panic(err) }
unsafe {
copy(&u8(slice.data), source.data, source.len)
}
if uploads.flush(slice) != .success {
return error('could not flush upload slice')
}
// Record a copy from uploads.buffer at slice.offset, submit it, and keep slice.
// After the protecting fence or timeline value has completed:
retired := uploads.retire(slice)
assert retiredSlices 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.
new_upload_ring() deliberately requires host-coherent memory, so its
flush() calls are checked no-ops. Advanced callers can use
new_upload_ring_with_options(..., AllocationOptions{ usage: .upload }) to
permit other host-visible types; flushing each written slice then handles
non-coherent memory correctly.
.stagingrequires host-visible and host-coherent memory and may be mapped..gpurequires device-local memory and normally cannot be mapped..first_availableselects the first memory type permitted by Vulkan'smemoryTypeBits, regardless of its property flags.
- Keep and pass the single
Allocatorinstance returned bynew(); do not copy it after allocations begin. Copies would refer to the same Vulkan blocks and planner while carrying separate mutable bookkeeping. - An
AllocationInfobelongs to the allocator that created it. - A successful
release()clears the completeAllocationInfoand 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, 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 matchingunmap(). - The allocator does not relocate live resources. Image sharing is opt-in and excludes sparse, disjoint, and DRM-format-modifier images.
- Heap budgets guide selection but cannot enforce a process-wide or system-wide limit because other allocators can change process usage and external system activity can change the budget concurrently.
- Vulkan objects must not outlive the memory bound to them.
All allocation and binding functions return vk.Result; callers should handle
errors instead of assuming allocation succeeds.
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:
v test .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:
v run examples/buffer_suballocationThe separate sustained workload performs 1,536 real Vulkan buffer allocations with mapped writes, flushes, fragmenting release/refill cycles, bounded-trace wraparound, and final coalescing checks:
v run examples/stressThe image suballocation example verifies that two optimal images share one real block while a linear image and buffer use separate class-compatible blocks:
v run examples/image_suballocationCI executes all examples against Mesa's CPU Vulkan implementation with the Khronos validation layer enabled, so it does not depend on a hardware GPU and binding-rule regressions remain visible.