Chapter 06: Memory¶
Overview¶
Efficient memory management is crucial for GPU performance. This chapter covers:
- Memory heaps and types in depth
- Allocation strategies
- Memory aliasing and suballocation
- When to use different memory types
What you'll learn:
- Understanding GPU memory architecture
- Optimizing memory usage patterns
- Avoiding allocation overhead
Memory Architecture¶
Heaps¶
Physical memory pools:
VkPhysicalDeviceMemoryProperties mem_props;
vkGetPhysicalDeviceMemoryProperties(physical_device, &mem_props);
for (uint32_t i = 0; i < mem_props.memoryHeapCount; i++) {
VkMemoryHeap heap = mem_props.memoryHeaps[i];
printf("Heap %u: %.2f GB", i, heap.size / 1e9);
if (heap.flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) {
printf(" (Device Local)"); // VRAM
}
printf("\n");
}
Typical discrete GPU:
Integrated GPU (or Apple Silicon):
Memory Types¶
Each type belongs to a heap and has properties:
for (uint32_t i = 0; i < mem_props.memoryTypeCount; i++) {
VkMemoryType type = mem_props.memoryTypes[i];
printf("Type %u (Heap %u): ", i, type.heapIndex);
if (type.propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)
printf("DEVICE_LOCAL ");
if (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT)
printf("HOST_VISIBLE ");
if (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)
printf("HOST_COHERENT ");
if (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT)
printf("HOST_CACHED ");
}
Choosing Memory Types¶
For Compute Buffers (GPU Only)¶
For Upload (CPU → GPU)¶
For Readback (GPU → CPU)¶
// GPU writes, CPU reads with cache
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT |
VK_MEMORY_PROPERTY_HOST_CACHED_BIT
Memory Allocation Limits¶
Vulkan has a limit on the number of allocations (typically ~4096):
VkPhysicalDeviceProperties props;
vkGetPhysicalDeviceProperties(physical_device, &props);
// props.limits.maxMemoryAllocationCount
Don't Allocate Per-Buffer
Creating 10,000 buffers with 10,000 allocations will fail!
Suballocation¶
Allocate large blocks, suballocate within:
┌────────────────────────────────────────────────────────┐
│ Large Allocation (64 MB) │
├──────────────┬──────────────┬──────────────┬───────────┤
│ Buffer A │ Buffer B │ Buffer C │ Free │
│ (16 MB) │ (8 MB) │ (32 MB) │ (8 MB) │
└──────────────┴──────────────┴──────────────┴───────────┘
Basic Suballocator¶
typedef struct {
VkDeviceMemory memory;
VkDeviceSize size;
VkDeviceSize offset; // Current allocation point
} MemoryBlock;
VkDeviceSize suballocate(MemoryBlock* block, VkDeviceSize size,
VkDeviceSize alignment) {
// Align offset
VkDeviceSize aligned_offset = (block->offset + alignment - 1)
& ~(alignment - 1);
if (aligned_offset + size > block->size) {
return UINT64_MAX; // Out of space
}
VkDeviceSize result = aligned_offset;
block->offset = aligned_offset + size;
return result;
}
Binding with Offset¶
VkDeviceSize offset = suballocate(&block, buffer_size, alignment);
vkBindBufferMemory(device, buffer, block.memory, offset);
Memory Aliasing¶
Multiple buffers can share the same memory (if not used simultaneously):
// Allocate once
VkDeviceSize total_size = max(size_a, max(size_b, size_c));
VkDeviceMemory memory;
vkAllocateMemory(device, &alloc_info, NULL, &memory);
// Bind multiple buffers to same memory
vkBindBufferMemory(device, buffer_a, memory, 0);
vkBindBufferMemory(device, buffer_b, memory, 0);
vkBindBufferMemory(device, buffer_c, memory, 0);
Aliasing Rules
Only one aliased buffer can be "active" at a time. Use barriers to transition between them.
Dedicated Allocations¶
Some resources benefit from dedicated memory:
VkMemoryDedicatedRequirements dedicated_reqs = {
.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_REQUIREMENTS
};
VkMemoryRequirements2 mem_reqs2 = {
.sType = VK_STRUCTURE_TYPE_MEMORY_REQUIREMENTS_2,
.pNext = &dedicated_reqs
};
VkBufferMemoryRequirementsInfo2 buf_reqs_info = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_REQUIREMENTS_INFO_2,
.buffer = buffer
};
vkGetBufferMemoryRequirements2(device, &buf_reqs_info, &mem_reqs2);
if (dedicated_reqs.requiresDedicatedAllocation ||
dedicated_reqs.prefersDedicatedAllocation) {
// Use dedicated allocation
VkMemoryDedicatedAllocateInfo dedicated_info = {
.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO,
.buffer = buffer
};
alloc_info.pNext = &dedicated_info;
}
Memory Budget¶
Query available memory (with extension):
// Requires VK_EXT_memory_budget
VkPhysicalDeviceMemoryBudgetPropertiesEXT budget = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT
};
VkPhysicalDeviceMemoryProperties2 props2 = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_PROPERTIES_2,
.pNext = &budget
};
vkGetPhysicalDeviceMemoryProperties2(physical_device, &props2);
for (uint32_t i = 0; i < props2.memoryProperties.memoryHeapCount; i++) {
printf("Heap %u: %llu / %llu MB used\n", i,
budget.heapUsage[i] / (1024*1024),
budget.heapBudget[i] / (1024*1024));
}
Running the Example¶
Output from an Apple M3 Pro, abridged:
=== Memory Heaps (1) ===
Heap 0:
Size: 18.00 GB
Flags: DEVICE_LOCAL
=== Memory Types (3) ===
Type 0 (Heap 0):
Properties: DEVICE_LOCAL
Best for: GPU-only data (fastest compute)
Type 1 (Heap 0):
Properties: DEVICE_LOCAL HOST_VISIBLE HOST_COHERENT HOST_CACHED
Best for: Staging buffers, uniform buffers
Type 2 (Heap 0):
Properties: DEVICE_LOCAL LAZILY_ALLOCATED
Best for: GPU-only data (fastest compute)
=== Memory Limits ===
Max Memory Allocations: 1073741824
Buffer-Image Granularity: 16 bytes
Non-Coherent Atom Size: 16 bytes
=== Part 2: Alignment Requirements ===
Storage Buffer (1024 bytes):
Actual size needed: 1024 bytes
Alignment required: 256 bytes
Memory type bits: 0x3
=== Part 3: Sub-allocation ===
Allocating 16.00 MB block for multiple buffers...
Buffer 0: size=1024, offset=0, aligned to 256
Buffer 1: size=4096, offset=1024, aligned to 256
Buffer 2: size=2048, offset=5120, aligned to 256
Buffer 3: size=8192, offset=7168, aligned to 256
Total used: 15360 / 16777216 bytes
=== Part 4: Compute on Sub-allocated Buffer ===
Buffer 1 after compute (doubled):
[0] = 2000.0 (was 1000.0)
[1] = 2002.0 (was 1001.0)
[2] = 2004.0 (was 1002.0)
=== Part 5: Memory Access Performance ===
Host-visible coherent: 0.09 ms per 4MB upload (41.29 GB/s)
Chapter 06 completed!
Sub-allocation offsets
Buffer 1 lands at offset 1024 even though the alignment is 256 — the offset only has to be a multiple of the alignment, and 1024 already is.
Best Practices¶
Do¶
- ✅ Suballocate from large blocks
- ✅ Use device-local for compute-heavy buffers
- ✅ Keep staging buffers mapped
- ✅ Pool similar allocations together
Don't¶
- ❌ Allocate per-buffer
- ❌ Exceed
maxMemoryAllocationCount - ❌ Map/unmap repeatedly (keep mapped)
- ❌ Use host-visible for compute-intensive data
Memory Allocator Libraries¶
For production, use a library:
- VMA (Vulkan Memory Allocator): AMD's widely-used allocator
- D3D12MA: Can be adapted for Vulkan concepts
// VMA example
VmaAllocatorCreateInfo allocator_info = {...};
VmaAllocator allocator;
vmaCreateAllocator(&allocator_info, &allocator);
VmaAllocationCreateInfo alloc_ci = {
.usage = VMA_MEMORY_USAGE_GPU_ONLY
};
vmaCreateBuffer(allocator, &buffer_info, &alloc_ci,
&buffer, &allocation, NULL);
Exercises¶
-
Allocation Limit: Create buffers until you hit the allocation limit, then implement suballocation.
-
Memory Pressure: Allocate more than available VRAM and observe fallback to system memory.
-
Benchmark: Compare compute performance on host-visible vs device-local memory.
Common Errors¶
VK_ERROR_OUT_OF_DEVICE_MEMORY¶
- Check memory budget before allocating
- Implement suballocation
- Consider smaller working sets
Wrong Memory Type¶
Mapped memory returns garbage:
- Ensure HOST_VISIBLE flag
- Check HOST_COHERENT or flush manually
Alignment Violations¶
Buffer binding fails:
- Respect memoryRequirements.alignment
- Some buffers need stricter alignment (uniform buffers especially)
What's Next?¶
We've been using descriptors to pass data to shaders. In Chapter 07, we'll learn about push constants — a faster way to pass small amounts of data.