Chapter 05: Synchronization¶
Overview¶
GPUs execute commands asynchronously. This chapter covers how to synchronize:
- CPU-GPU synchronization with fences
- GPU-GPU synchronization with semaphores (reference; the sample uses a single queue)
- Memory dependencies with barriers
What you'll learn:
- When and why synchronization is needed
- Choosing the right synchronization primitive
- Avoiding race conditions and data hazards
Why Synchronization?¶
CPU GPU
│ │
├── Submit commands ────────▶│
│ (returns immediately) ├── Process cmd 1
│ ├── Process cmd 2
├── Read results? ◀──────────┤── Still working!
│ (WRONG!) │
│ ├── Done
├── Wait for fence ◀─────────┤
├── Read results │
Without synchronization: - Reading buffer while GPU is writing → garbage data - Submitting new work before previous completes → undefined behavior - Overwriting resources in use → corruption
Synchronization Primitives¶
| Primitive | Scope | Purpose |
|---|---|---|
| Fence | CPU ↔ GPU | Know when GPU work completes |
| Semaphore | GPU ↔ GPU | Order queue submissions |
| Barrier | Within command buffer | Order operations within a submit |
| Event | Fine-grained | Split barrier across commands |
Fences: CPU-GPU Sync¶
Creating a Fence¶
VkFenceCreateInfo fence_info = {
.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO,
.flags = 0 // Start unsignaled
};
VkFence fence;
vkCreateFence(device, &fence_info, NULL, &fence);
Submit with Fence¶
VkSubmitInfo submit_info = {
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.commandBufferCount = 1,
.pCommandBuffers = &cmd
};
vkQueueSubmit(queue, 1, &submit_info, fence);
Wait for Completion¶
// Block until GPU signals fence
vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX);
// Reset for reuse
vkResetFences(device, 1, &fence);
Polling (Non-blocking)¶
VkResult result = vkGetFenceStatus(device, fence);
if (result == VK_SUCCESS) {
// Work completed
} else if (result == VK_NOT_READY) {
// Still in progress
}
Semaphores: GPU-GPU Sync¶
Reference only
The ch05_synchronization sample does not use semaphores — it has a single
compute queue, where fences and barriers are sufficient. This section is
reference material for when you submit across multiple queues.
Semaphores order operations between queue submissions:
VkSemaphoreCreateInfo sem_info = {
.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO
};
VkSemaphore semaphore;
vkCreateSemaphore(device, &sem_info, NULL, &semaphore);
Signal and Wait¶
// Submission 1: Signal when done
VkSubmitInfo submit1 = {
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.commandBufferCount = 1,
.pCommandBuffers = &cmd1,
.signalSemaphoreCount = 1,
.pSignalSemaphores = &semaphore
};
vkQueueSubmit(queue, 1, &submit1, VK_NULL_HANDLE);
// Submission 2: Wait before starting
VkPipelineStageFlags wait_stage = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
VkSubmitInfo submit2 = {
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.waitSemaphoreCount = 1,
.pWaitSemaphores = &semaphore,
.pWaitDstStageMask = &wait_stage,
.commandBufferCount = 1,
.pCommandBuffers = &cmd2
};
vkQueueSubmit(queue, 1, &submit2, fence);
Pipeline Barriers: Memory Dependencies¶
Barriers ensure memory operations complete before subsequent operations start.
When Do You Need Barriers?¶
| Scenario | Barrier Needed? |
|---|---|
| Read after write (same buffer) | Yes |
| Write after read | Yes |
| Write after write | Yes |
| Read after read | No |
| Different buffers, no dependency | No |
Memory Barrier¶
VkMemoryBarrier barrier = {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // After this stage
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // Before this stage
0, // Flags
1, &barrier, // Memory barriers
0, NULL, // Buffer barriers
0, NULL); // Image barriers
Buffer Memory Barrier¶
For specific buffer ranges:
VkBufferMemoryBarrier buffer_barrier = {
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.buffer = buffer,
.offset = 0,
.size = VK_WHOLE_SIZE
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 0, NULL,
1, &buffer_barrier,
0, NULL);
Access Masks¶
Common access flags:
| Flag | Usage |
|---|---|
SHADER_READ_BIT |
Read from shader (storage/uniform buffer) |
SHADER_WRITE_BIT |
Write from shader (storage buffer) |
TRANSFER_READ_BIT |
Source of copy operation |
TRANSFER_WRITE_BIT |
Destination of copy operation |
HOST_READ_BIT |
CPU read after mapping |
HOST_WRITE_BIT |
CPU write after mapping |
Pipeline Stages¶
Common stage flags for compute:
| Flag | When |
|---|---|
TOP_OF_PIPE_BIT |
Very beginning |
COMPUTE_SHADER_BIT |
Compute shader execution |
TRANSFER_BIT |
Copy operations |
HOST_BIT |
CPU access |
BOTTOM_OF_PIPE_BIT |
Very end |
Multiple Independent Dispatches¶
When dispatches are independent, submit them together:
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline);
// These write to different buffers - no barrier needed between
vkCmdBindDescriptorSets(cmd, ..., &set_a, ...);
vkCmdDispatch(cmd, 256, 1, 1);
vkCmdBindDescriptorSets(cmd, ..., &set_b, ...);
vkCmdDispatch(cmd, 256, 1, 1);
vkCmdBindDescriptorSets(cmd, ..., &set_c, ...);
vkCmdDispatch(cmd, 256, 1, 1);
// One barrier after all, before reading any results
vkCmdPipelineBarrier(cmd, ...);
Running the Example¶
Output from an Apple M3 Pro:
==============================================
VKCompute - Chapter 05: Synchronization
==============================================
=== Part 1: Fences (CPU-GPU Sync) ===
Submitting work and waiting with fence...
Immediate check: Not ready
Fence signaled after 4.94 ms
Result: data[0] = 1 (was 0, incremented by 1)
=== Part 2: Pipeline Barriers ===
Running chained dispatches with barriers...
5 chained dispatches completed in 0.72 ms
Result: data[0] = 5 (expected 5)
=== Part 3: Buffer Memory Barriers ===
buf_a[0] = 1 (was 0, +1)
buf_a[5] = 6 (was 5, +1)
buf_b[0] = 1 (was 0, +1)
=== Part 4: Multiple Submissions ===
Submitting 3 independent operations...
All completed in 0.74 ms
buf_a[0] = 1 (1 dispatch)
buf_b[0] = 2 (2 dispatches)
buf_c[0] = 3 (3 dispatches)
Chapter 05 completed!
The chapter uses a single increment.comp shader throughout; the parts differ
in how work is synchronized, not in what it computes.
Common Patterns¶
Wait Idle (Simple but Slow)¶
Performance
WaitIdle is simple but prevents CPU-GPU overlap. Use fences for production code.
Double Buffering¶
Overlap CPU and GPU work:
VkFence fences[2];
VkCommandBuffer cmds[2];
int frame = 0;
while (running) {
// Wait for previous frame's GPU work
vkWaitForFences(device, 1, &fences[frame], VK_TRUE, UINT64_MAX);
vkResetFences(device, 1, &fences[frame]);
// Record new commands (CPU work)
record_commands(cmds[frame]);
// Submit (starts GPU work)
vkQueueSubmit(queue, 1, &submit, fences[frame]);
frame = 1 - frame; // Alternate
}
Exercises¶
-
Fence Timing: Measure actual GPU execution time by recording timestamps before and after fence wait.
-
Parallel Reduction: Implement a multi-pass reduction with proper barriers between passes.
-
Producer-Consumer: Set up two queues where one produces data and another consumes it.
Common Errors¶
Data Race¶
Symptoms: Intermittent wrong results, works sometimes
// Missing barrier!
vkCmdDispatch(cmd, 64, 1, 1); // Writes to buffer
vkCmdDispatch(cmd, 64, 1, 1); // Reads same buffer - RACE!
Deadlock¶
Waiting on fence that was never submitted:
vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX); // Hangs!
// Forgot to call vkQueueSubmit with this fence
Wrong Stage Mask¶
Barrier doesn't actually synchronize:
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, // Too early!
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
...);
What's Next?¶
We've touched on memory types in Chapter 02. In Chapter 06, we'll go deeper into Vulkan's memory model and advanced allocation strategies.