Chapter 02: Buffers¶
Overview¶
In this chapter, you'll learn how to transfer data between CPU and GPU using Vulkan buffers:
- Create GPU buffers
- Understand memory types (host-visible vs device-local)
- Upload data to the GPU
- Run a simple compute shader
- Read results back
What you'll learn:
- Buffer creation and memory allocation
- Memory property flags
- Staging buffers for optimal performance
- Memory mapping
Introducing the vkc Helper Library
Starting with this chapter, we use the vkc helper library to reduce boilerplate. Chapter 1 showed raw Vulkan to teach initialization; from now on, we wrap common patterns so we can focus on new concepts.
#include "vk_init.h" // VkcContext, buffer + shader + pipeline helpers
#include "vk_utils.h" // VK_CHECK macros, logging, timing
#include "file_utils.h" // File I/O and path resolution
The library is intentionally simple — you can read the source in the vkc/ folder to see the raw Vulkan calls.
GPU Memory Architecture¶
Before diving into code, understand that GPUs have different memory types:
┌─────────────────────────────────────────────────────────┐
│ CPU │
│ ┌─────────────────────────────────────────────────┐ │
│ │ System RAM (Host Memory) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
PCIe Bus (slower)
│
┌─────────────────────────────────────────────────────────┐
│ GPU │
│ ┌─────────────────────────────────────────────────┐ │
│ │ VRAM (Device Local) │ │
│ │ - Fastest for GPU │ │
│ │ - Not directly CPU accessible │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Shared Memory (Host Visible) │ │
│ │ - CPU can read/write │ │
│ │ - Slower for GPU compute │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Memory Types¶
Vulkan exposes different memory types with property flags:
| Flag | Meaning |
|---|---|
DEVICE_LOCAL |
Fastest for GPU, typically VRAM |
HOST_VISIBLE |
CPU can map and access |
HOST_COHERENT |
No manual flush/invalidate needed |
HOST_CACHED |
CPU reads are cached (faster) |
Common combinations:
// Fast GPU access, CPU can't touch directly
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
// CPU can write, good for uploads
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
// CPU can read efficiently
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT |
VK_MEMORY_PROPERTY_HOST_CACHED_BIT
Creating a Buffer¶
Buffer creation is a two-step process:
Step 1: Create the Buffer Object¶
VkBufferCreateInfo buffer_info = {
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = 1024 * sizeof(float), // 4 KB
.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE
};
VkBuffer buffer;
vkCreateBuffer(device, &buffer_info, NULL, &buffer);
Step 2: Allocate and Bind Memory¶
// Query memory requirements
VkMemoryRequirements mem_reqs;
vkGetBufferMemoryRequirements(device, buffer, &mem_reqs);
// Find suitable memory type
uint32_t memory_type = find_memory_type(
mem_reqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
);
// Allocate memory
VkMemoryAllocateInfo alloc_info = {
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = mem_reqs.size,
.memoryTypeIndex = memory_type
};
VkDeviceMemory memory;
vkAllocateMemory(device, &alloc_info, NULL, &memory);
// Bind memory to buffer
vkBindBufferMemory(device, buffer, memory, 0);
Finding Memory Type¶
uint32_t find_memory_type(uint32_t type_filter,
VkMemoryPropertyFlags properties) {
VkPhysicalDeviceMemoryProperties mem_props;
vkGetPhysicalDeviceMemoryProperties(physical_device, &mem_props);
for (uint32_t i = 0; i < mem_props.memoryTypeCount; i++) {
if ((type_filter & (1 << i)) &&
(mem_props.memoryTypes[i].propertyFlags & properties)
== properties) {
return i;
}
}
return UINT32_MAX; // Not found
}
Uploading Data¶
For host-visible memory, map and copy:
// Map memory
void* mapped;
vkMapMemory(device, memory, 0, buffer_size, 0, &mapped);
// Copy data
memcpy(mapped, source_data, buffer_size);
// Unmap (optional if you'll write again)
vkUnmapMemory(device, memory);
Persistent Mapping
For frequently updated buffers, keep memory mapped. Don't map/unmap every frame.
The Staging Buffer Pattern¶
For best performance, use device-local memory for compute. But CPU can't write directly to it. Solution: staging buffer.
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ CPU Memory │────▶│ Staging Buffer │────▶│ Device Buffer │
│ (your data) │copy │ (host-visible) │GPU │ (device-local) │
│ │ │ │copy │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
Implementation¶
// 1. Create staging buffer (host-visible)
VkBuffer staging_buffer;
VkDeviceMemory staging_memory;
create_buffer(size,
VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
&staging_buffer, &staging_memory);
// 2. Create device buffer (device-local)
VkBuffer device_buffer;
VkDeviceMemory device_memory;
create_buffer(size,
VK_BUFFER_USAGE_TRANSFER_DST_BIT |
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
&device_buffer, &device_memory);
// 3. Copy data to staging buffer
void* mapped;
vkMapMemory(device, staging_memory, 0, size, 0, &mapped);
memcpy(mapped, source_data, size);
vkUnmapMemory(device, staging_memory);
// 4. GPU copy: staging -> device
VkCommandBuffer cmd = begin_single_time_commands();
VkBufferCopy copy_region = {
.srcOffset = 0,
.dstOffset = 0,
.size = size
};
vkCmdCopyBuffer(cmd, staging_buffer, device_buffer, 1, ©_region);
end_single_time_commands(cmd);
// 5. Staging buffer can be freed now
vkDestroyBuffer(device, staging_buffer, NULL);
vkFreeMemory(device, staging_memory, NULL);
Reading Data Back¶
Same pattern in reverse for reading results:
// GPU copy: device -> staging
vkCmdCopyBuffer(cmd, device_buffer, staging_buffer, 1, ©_region);
// Wait for completion, then read from staging
void* mapped;
vkMapMemory(device, staging_memory, 0, size, 0, &mapped);
memcpy(dest_data, mapped, size);
vkUnmapMemory(device, staging_memory);
The Compute Shader¶
Here's a simple shader that squares each value:
#version 450
layout(local_size_x = 256) in;
layout(set = 0, binding = 0) buffer DataBuffer {
float data[];
};
void main() {
uint idx = gl_GlobalInvocationID.x;
float val = data[idx];
data[idx] = val * val;
}
Barriers Around the Copies¶
A staging copy and the shader that reads it are two different pipeline stages. The transfer write is not automatically visible to the compute shader, so the copy command buffer ends with a barrier:
VkBufferMemoryBarrier to_compute = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.buffer = device_buffer.buffer,
.offset = 0,
.size = VK_WHOLE_SIZE
};
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 0, NULL, 1, &to_compute, 0, NULL);
Reading results back needs the mirror image — SHADER_WRITE to
TRANSFER_READ — before the device-to-staging copy.
Unified memory hides this bug
On Apple silicon the program still produces correct values without these barriers, because host and device memory are the same. On a discrete GPU the same code races. Chapter 05 covers synchronization in full.
Running the Example¶
Output from an Apple M3 Pro. Because Apple silicon has unified memory, it
reports a single device-local heap and every type is DEVICE_LOCAL; a
discrete GPU will show a separate host heap and host-only types:
==============================================
VKCompute - Chapter 02: Buffer Types
==============================================
=== Memory Heaps ===
Heap 0: 18.00 GB (Device Local)
=== Memory Types ===
Type 0 (Heap 0): DEVICE_LOCAL
Type 1 (Heap 0): DEVICE_LOCAL HOST_VISIBLE HOST_COHERENT HOST_CACHED
Type 2 (Heap 0): DEVICE_LOCAL
Buffer size: 4.00 MB
--- Method 1: Host Visible Buffer ---
Upload time: 0.33 ms (11.91 GB/s)
--- Method 2: Staging Buffer + Device Local ---
Staging upload: 0.40 ms
GPU copy time: 4.01 ms (0.97 GB/s)
--- Running Compute Shader ---
Compute time: 0.98 ms
--- Reading Back Results ---
GPU readback copy: 0.70 ms
Download time: 0.49 ms
=== Verification ===
First 5 values:
0² = 0 (expected 0)
1² = 1 (expected 1)
2² = 4 (expected 4)
3² = 9 (expected 9)
4² = 16 (expected 16)
All 1048576 values computed correctly!
Performance Considerations¶
| Approach | Upload Speed | Compute Speed | Best For |
|---|---|---|---|
| Host-visible only | Fast | Slower | Small buffers, frequent updates |
| Staging + Device | Slower setup | Fastest | Large buffers, compute-intensive |
Unified Memory (Integrated GPUs)
On integrated GPUs (like Apple M-series), memory is often unified. Device-local memory may also be host-visible, eliminating the need for staging.
Buffer Usage Flags¶
| Flag | Purpose |
|---|---|
STORAGE_BUFFER_BIT |
Read/write from shaders |
UNIFORM_BUFFER_BIT |
Read-only constants |
TRANSFER_SRC_BIT |
Source for copy operations |
TRANSFER_DST_BIT |
Destination for copy operations |
Combine flags as needed:
Exercises¶
-
Memory Comparison: Benchmark host-visible vs device-local memory for different buffer sizes.
-
Persistent Mapping: Modify the code to keep the staging buffer mapped and reuse it.
-
Different Operations: Change the shader to perform different operations (add, multiply, etc.).
Common Errors¶
VK_ERROR_OUT_OF_DEVICE_MEMORY¶
- Buffer too large for GPU
- Too many allocations (Vulkan has a limit, ~4096 on some GPUs)
- Use a memory allocator library for production code
Data Corruption¶
- Forgot
HOST_COHERENTflag — needvkFlushMappedMemoryRanges - Didn't wait for GPU to finish before reading
- Buffer size mismatch between CPU and shader
What's Next?¶
We can create buffers and run shaders, but how do we tell the shader where to find the buffers? In Chapter 03, we'll learn about descriptor sets — Vulkan's resource binding mechanism.