35 - Synchronisation 2
This step is a bit different from the others: nothing changes in the main.odin. The work happened inside libs/ovk, where the last two legacy synchronization calls were replaced with their modern *2 versions.
The codebase was already using vkCmdPipelineBarrier2 for image layout transitions since step 13, and synchronization2 = true was enabled at device creation. But two functions still spoke Vulkan 1.0: cmd_generate_mipmaps recorded vkCmdPipelineBarrier, and submit_command_buffer called vkQueueSubmit. This step migrates both to vkCmdPipelineBarrier2 and vkQueueSubmit2.
The full source for this step lives in src/35_synchronisation_2/main.odin and the changed library files are libs/ovk/commands.odin and libs/ovk/command_buffer.odin.
References:
- Synchronization introduction - the official tutorial’s synchronization chapter, with the concepts in more depth.
- Vulkan SDK offers developers a smooth transition path to synchronization 2 - Khronos blog post explaining why the new API exists.
Objectives
- Explain what synchronization 2 is and why it exists.
- Replace
vkCmdPipelineBarrierwithvkCmdPipelineBarrier2incmd_generate_mipmaps. - Replace
vkQueueSubmitwithvkQueueSubmit2insubmit_command_buffer.
Concepts
The two problems with the original synchronization API
The barrier and submit functions from Vulkan 1.0 do the job, but two design decisions make them clumsy.
Stages are a property of the whole barrier call. vkCmdPipelineBarrier takes a single srcStageMask and dstStageMask shared by every barrier listed in the call. The access masks are per-barrier, but the stages are not. When you want two barriers in one call to run at different stages, you widen the masks to cover both cases. That synchronizes more than needed, and the more barriers you lump together, the more you over-sync.
The submit has no signal stage. vkQueueSubmit lets you say which stage waits on each semaphore, but a signaled semaphore has no stage of its own. That is fine for binary semaphores used as “the frame is done” flags, but it is vague when you want finer ordering, and it is part of why timeline semaphores were awkward to bolt on.
What synchronization 2 changes
VK_KHR_synchronization2, core since Vulkan 1.3, reworks both calls (see the References above for background). The short version:
-
vkCmdPipelineBarrier2takes aVkDependencyInfo, and everyVkImageMemoryBarrier2/VkBufferMemoryBarrier2/VkMemoryBarrier2inside it carries its own stage and access masks. The dependency is complete in one struct: “these stages, with these accesses, must wait for those stages, with those accesses.” No common denominator. -
vkQueueSubmit2takes aVkSubmitInfo2in which each semaphore - wait or signal - is aVkSemaphoreSubmitInfowith its ownstageMask. Signal semaphores get a stage too. The same struct has avaluefield, which is what makes timeline semaphores work natively.
Why it’s worth the migration
- Precision. You describe exactly what must happen, so the driver can overlap more work. Over-synchronization is the classic Vulkan performance trap, and the old API pushed you toward it.
- Readability. The sync for a dependency lives on the barrier itself. You don’t have to mentally pair a global stage mask with the right barrier in a list.
- It’s the base for what’s next. Timeline semaphores, the natural evolution of the fence + binary semaphore combos used so far, build directly on the submit2 structures.
One rule of thumb: the old and new functions can coexist in a command buffer, but there’s no reason to mix them once the device exposes synchronization 2.
Implementation
libs/ovk/commands.odin - cmd_generate_mipmaps
Before, the loop recorded the old form. Stage masks went on the command, access masks on the barrier:
vk.CmdPipelineBarrier(command_buffer, {.TRANSFER}, {.TRANSFER}, {}, 0, nil, 0, nil, 1, &barrier)
After, the barrier carries everything, and one DependencyInfo is reused for all the transitions:
barrier := vk.ImageMemoryBarrier2 {
sType = .IMAGE_MEMORY_BARRIER_2,
image = image.vk_image,
srcQueueFamilyIndex = vk.QUEUE_FAMILY_IGNORED,
dstQueueFamilyIndex = vk.QUEUE_FAMILY_IGNORED,
subresourceRange = {{.COLOR}, 0, 1, 0, 1},
}
dependency_info := vk.DependencyInfo {
sType = .DEPENDENCY_INFO,
imageMemoryBarrierCount = 1,
pImageMemoryBarriers = &barrier,
}
for i in 1 ..< mip_levels {
barrier.subresourceRange.baseMipLevel = i - 1
barrier.oldLayout = .TRANSFER_DST_OPTIMAL
barrier.newLayout = .TRANSFER_SRC_OPTIMAL
barrier.srcStageMask = {.TRANSFER}
barrier.dstStageMask = {.TRANSFER}
barrier.srcAccessMask = {.TRANSFER_WRITE}
barrier.dstAccessMask = {.TRANSFER_READ}
vk.CmdPipelineBarrier2(command_buffer.vk_command_buffer, &dependency_info)
// ... blit from mip i-1 to mip i ...
vk.CmdBlitImage(command_buffer.vk_command_buffer, image.vk_image, .TRANSFER_SRC_OPTIMAL, image.vk_image, .TRANSFER_DST_OPTIMAL, 1, &blit, .LINEAR)
barrier.oldLayout = .TRANSFER_SRC_OPTIMAL
barrier.newLayout = .SHADER_READ_ONLY_OPTIMAL
barrier.srcStageMask = {.TRANSFER}
barrier.dstStageMask = {.FRAGMENT_SHADER}
barrier.srcAccessMask = {.TRANSFER_READ}
barrier.dstAccessMask = {.SHADER_READ}
vk.CmdPipelineBarrier2(command_buffer.vk_command_buffer, &dependency_info)
}
The stage masks that used to sit on the CmdPipelineBarrier call moved into the barrier itself. The dependency_info is built once and reused, since only the barrier’s fields change between calls.
The migration also fixed a small wart: the old code set srcQueueFamilyIndex and dstQueueFamilyIndex to 0 with a comment claiming VK_QUEUE_FAMILY_IGNORED. Zero is the real queue family zero, not “ignored”. Now it’s the actual constant.
libs/ovk/command_buffer.odin - submit_command_buffer
Before, semaphores were flat arrays and the wait stage was a separate pointer:
submit_info := vk.SubmitInfo {
// ...
pWaitSemaphores = raw_data(vk_wait_semaphores),
pWaitDstStageMask = raw_data(args.wait_dest_stages),
pCommandBuffers = &args.command_buffer.vk_command_buffer,
pSignalSemaphores = raw_data(vk_signal_semaphores),
}
vk.QueueSubmit(args.queue.vk_queue, 1, &submit_info, fence)
After, each semaphore gets its own SemaphoreSubmitInfo, and the command buffer gets a CommandBufferSubmitInfo:
for wait_semaphore, i in args.wait_semaphores {
wait_semaphore_infos[i] = vk.SemaphoreSubmitInfo {
sType = .SEMAPHORE_SUBMIT_INFO,
semaphore = wait_semaphore.vk_semaphore,
stageMask = args.wait_dest_stages[i],
}
}
for signal_semaphore, i in args.signal_semaphores {
signal_semaphore_infos[i] = vk.SemaphoreSubmitInfo {
sType = .SEMAPHORE_SUBMIT_INFO,
semaphore = signal_semaphore.vk_semaphore,
stageMask = {.ALL_COMMANDS},
}
}
command_buffer_info := vk.CommandBufferSubmitInfo {
sType = .COMMAND_BUFFER_SUBMIT_INFO,
commandBuffer = args.command_buffer.vk_command_buffer,
}
submit_info := vk.SubmitInfo2 {
sType = .SUBMIT_INFO_2,
waitSemaphoreInfoCount = u32(len(wait_semaphore_infos)),
pWaitSemaphoreInfos = raw_data(wait_semaphore_infos),
commandBufferInfoCount = 1,
pCommandBufferInfos = &command_buffer_info,
signalSemaphoreInfoCount = u32(len(signal_semaphore_infos)),
pSignalSemaphoreInfos = raw_data(signal_semaphore_infos),
}
vk.QueueSubmit2(args.queue.vk_queue, 1, &submit_info, fence)
Signal semaphores now carry a stage too. {.ALL_COMMANDS} is the conservative choice: “signal once everything above has finished”, which is exactly what the old call meant implicitly.
The only signature change is wait_dest_stages, which went from []vk.PipelineStageFlags to []vk.PipelineStageFlags2. The callers didn’t have to change: swap_chain_helper_submit_and_queue_present passes {{.COLOR_ATTACHMENT_OUTPUT}}, and that bit has the same value in both enums, so the literal still compiles as-is.
Results
The app runs exactly as before: same viking room, same rotation. This step exercises the new code on two paths without touching it directly:
- Every frame,
swap_chain_helper_submit_and_queue_presentgoes through the rewrittensubmit_command_buffer, sovkQueueSubmit2is on the hot path from the first frame. - At startup,
create_image_from_filecalls the rewrittencmd_generate_mipmaps, sovkCmdPipelineBarrier2handles every layout transition and per-level barrier in the mip chain.
Validation layers report nothing new. The synchronization2 feature has been required and enabled since step 13, so nothing had to change in device creation for these calls to work.
That’s it. Synchronization was the last piece of the codebase still speaking Vulkan 1.0, and the whole library is now consistently on the modern API.