Skip to content

Latest commit

 

History

29 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vulkan memory allocation helper for V

Project portfolio

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.

How it fits together

The allocator has four deliberately separate layers:

  1. 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 MemoryTypeChoice explains the heap, flags, budget state, and score.
  2. Block planning uses antono2.memory.RangeAllocator to place compatible resources into larger blocks. Every block has both a Vulkan memory type and a resource class (buffer, linear_image, or optimal_image), so classes with a bufferImageGranularity boundary rule never become adjacent. This CPU-only layer is deterministic and independently tested.
  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.

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.

Install

v install antono2.vkmemalloc

VPM 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.vsh

Use 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.

Basic use

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.

Policy-based allocation

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_only requires device-local memory.
  • .upload requires host-visible memory and prefers coherent, device-local types.
  • .readback requires host-visible memory and prefers cached, coherent types.
  • .automatic has 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.

Image allocation

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.

Statistics

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.

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:

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.

Heap budgets

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.

Persistent upload ring

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 retired

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.

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.

Legacy memory classes

  • .staging requires host-visible and host-coherent memory and may be mapped.
  • .gpu requires device-local memory and normally cannot be mapped.
  • .first_available selects the first memory type permitted by Vulkan's memoryTypeBits, regardless of its property flags.

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, 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. 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.

Tests

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_suballocation

The 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/stress

The 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_suballocation

CI 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.

About

Budget-aware Vulkan memory allocation for V, with block suballocation, upload rings, fragmentation diagnostics, and sustained-load validation.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages