Vulkan的同步问题

GPU是高度并行化的设备,其不仅对处理的数据是并行化的,而且GPU的渲染流程中各个阶段的处理也都是并行化的。如果彼此间真的不存在关联,那么这种并行化就没有任何问题,但是当各个处理流程或者数据间是有关联的,那么这个并行就麻烦了,他们之间一定要有一定的先后顺序,否则就一定后导致混乱的发生。比如说对同一资源的读写访问等,一个读,一个写,如果没有相应的同步机制保障,则必然后导致读写混乱。

vulkan提供了一套完善而复杂的同步逻辑来保证GPU的并发执行,下面我们就来分析一下。

同步原语

Fence

fence用于CPU与GPU之间的同步.

比如说对于UniformBuffer,CPU负责修改其内容,GPU负责读取其内容,由于CPU与GPU是同时各自独立执行的,那么就必然会有同时读写的存在,fence就是为了解决这个问题而产生的。

首先创建fence,并将其设置为unsignaled的状态

1
vkCreateFence(device, &fenceInfo, nullptr, &inFlightFences[i])

然后再draw的时候,需要调用vkWaitForFences,让CPU在当前位置被阻塞掉,然后一直等待到它接受的Fence变为signaled的状态,这样就可以实现在某个渲染队列内的所有任务被完成后,CPU再执行某些操作的同步情景,如UpdateUniformBuffer等操作

然后调用vkResetFences(device, 1, &inFlightFences[currentFrame])函数将fence的状态切换为unsignaled

最后在调用vkQueueSubmit提交命令时,传入unsignaled的fence

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void drawFrame() {
vkWaitForFences(device, 1, &inFlightFences[currentFrame], VK_TRUE, UINT64_MAX);

uint32_t imageIndex;
VkResult result = vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);

if (result == VK_ERROR_OUT_OF_DATE_KHR) {
recreateSwapChain();
return;
}
else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) {
throw std::runtime_error("failed to acquire swap chain image!");
}

updateUniformBuffer(imageIndex);

if (imagesInFlight[imageIndex] != VK_NULL_HANDLE) {
vkWaitForFences(device, 1, &imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX);
}
imagesInFlight[imageIndex] = inFlightFences[currentFrame];

VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;

VkSemaphore waitSemaphores[] = { imageAvailableSemaphores[currentFrame] };
VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = waitSemaphores;
submitInfo.pWaitDstStageMask = waitStages;

submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers[imageIndex];

VkSemaphore signalSemaphores[] = { renderFinishedSemaphores[currentFrame] };
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores;

vkResetFences(device, 1, &inFlightFences[currentFrame]);

if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]) != VK_SUCCESS) {
throw std::runtime_error("failed to submit draw command buffer!");
}
......
}

Semaphore

semaphore用于同步GPU不同的queue之间,或者同一个queue不同的submission之间的执行顺序。

比如说在渲染流程中一般至少存在两个并发任务,一个是渲染(render),一个是呈现(present)。他们都是GPU的任务,按照业务逻辑,需要首先完成渲染,然后再进行呈现。而同样的渲染和呈现是分开独立进行的,如果没有干预,会发生把正在渲染的半成品呈现到屏幕的问题,因此需要同步机制干预,vulkan中用Semaphore来解决这个问题。

一般来讲,对于上面例子的任务,会设置两个Semaphore:

1
2
VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;

将其设置到提交渲染命令(vkQueueSubmit)的信息中,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;

VkSemaphore waitSemaphores[] = { imageAvailableSemaphore };
VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = waitSemaphores; // 需要等待的semaphore
submitInfo.pWaitDstStageMask = waitStages;

submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers[imageIndex];

VkSemaphore signalSemaphores[] = { renderFinishedSemaphore};
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = signalSemaphores; // 需要设置的semaphore

vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]);

也就是说在本次提交到Queue中的命令commandBuffers[imageIndex];需要等待imageAvailableSemaphore,然后再执行完毕后,设置renderFinishedSemaphore

在present任务中,需要等待renderFinishedSemaphore

1
2
3
4
5
6
7
8
9
10
11
12
13
VkPresentInfoKHR presentInfo{};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;

presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = signalSemaphores;

VkSwapchainKHR swapChains[] = { swapChain };
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = swapChains;

presentInfo.pImageIndices = &imageIndex;

result = vkQueuePresentKHR(presentQueue, &presentInfo);

这样就解决了上面提到的同步问题。

但是上面这样搞,原本的并行就变为了串行了啊,因此在具体的业务实现中,一般采用多buffer的机制,来实现并行处理。

通过上面的例子,我们可以看到Semaphore是解决每次提交的一批命令与另一批命令之间的同步关系的。那么同一批commands之中的command的同步要怎么解决呢?下面的Barrier就会解决这个问题

Event

Event是一个细粒度的同步原语,用于精确的界定管线里发生的操作。

Event用于同步提交到同一队列的不同命令,或者同步CPU和队列。Event不能用于不同队列的命令间的同步。

Event有两种状态signaled和unsignaled。

无论是设备还是主机,都可以直接操作event,不过他们是有区别的,主机可以使用vkGetEventStatus立即获取事件对象的状态,但是不能直接等待事件,而设备正好相反,不能直接获取事件对象的状态,但是能等待一个或者多个事件。

Barrier

上面的fence用于CPU与GPU之间的同步,semaphore用于GPU与GPU之间不同submission间的commandBuffers的同步问题,那么同一个commandBuffers中的不同的command之间的同步问题,该怎么办呢?vulkan提供了Barrier来解决。

Barrier实际上是屏障的意思,something material that blocks or is intended to block passage,就是阻挡通道的一些blocks。那么在queue的commands里面就是插入一个barrier,将所有的commands分成两部分,一部分执行完毕了,后面一部分才能执行。

不同的command执行的先后有什么问题么?如果不涉及到对资源的访问,那么就没什么问题。如果不同的command涉及到了对同一资源的访问以及修改,那么就有问题了,也正是因此我们看Barrier的设置函数中会专门对资源进行设置。

1
2
3
4
5
6
7
8
9
10
11
VKAPI_ATTR void VKAPI_CALL vkCmdPipelineBarrier(
VkCommandBuffer commandBuffer,
VkPipelineStageFlags srcStageMask,
VkPipelineStageFlags dstStageMask,
VkDependencyFlags dependencyFlags,
uint32_t memoryBarrierCount,
const VkMemoryBarrier* pMemoryBarriers,
uint32_t bufferMemoryBarrierCount,
const VkBufferMemoryBarrier* pBufferMemoryBarriers,
uint32_t imageMemoryBarrierCount,
const VkImageMemoryBarrier* pImageMemoryBarriers);

看上面的API,

命令的提交顺序

对于vulkan而言,所有的操作最终都是需要变为command,然后提交到queue中,然后执行的。

然而GPU是并行的,后提交的命令有可能在先提交的命令之前完成,所以需要引入各种的同步机制来保证渲染或者计算业务的正确运行。

虽然执行的快慢不一定,但是命令的提交,或者说执行是有序的,一般而言先提交的命令会先执行,但是不保证先执行的任务先完成。

同时在录制命令的时候,也是有提交顺序的,如下所示:

  • CPU通过多次调用vkQueueSubmit提交的命令,一定是按照vkQueueSubmit调用的先后顺序提交的,也就是说submitInfo中的command一定比submitInfo1中的任务先提交到Queue中。

    1
    2
    vkQueueSubmit(graphicsQueue, 1, &submitInfo, inFlightFences[currentFrame]);
    vkQueueSubmit(graphicsQueue, 1, &submitInfo1, inFlightFences[currentFrame]);
  • 在同一次vkQueueSubmit中,传入了一个或多个VkSubmitInfo,这些VkSubmitInfo中的命令,按照VkSubmitInfo的下标顺序排列,即在pSubmits所指向的VkSubmitInfo数组中,下标靠前的VkSubmitInfo中所记录的所有命令都在下标靠后的VkSubmitInfo中所记录的所有命令之前。也就是说submitInfo[0]一定比submitInfo[1]先提交到Queue中。

    1
    2
    3
    4
    5
    VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(
    VkQueue queue,
    uint32_t submitCount,
    const VkSubmitInfo* pSubmits,
    VkFence fence);
  • 同一个VkSubmitInfo中,填入了多个commandBuffer(pCommandBuffers指向数组),这些commandBuffer中的命令也是按照下标顺序提交,即commandBuffer[0]一定比commandBuffer[1]先提交到Queue中,和上面的逻辑一致。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    typedef struct VkSubmitInfo {
    VkStructureType sType;
    const void* pNext;
    uint32_t waitSemaphoreCount;
    const VkSemaphore* pWaitSemaphores;
    const VkPipelineStageFlags* pWaitDstStageMask;
    uint32_t commandBufferCount;
    const VkCommandBuffer* pCommandBuffers;
    uint32_t signalSemaphoreCount;
    const VkSemaphore* pSignalSemaphores;
    } VkSubmitInfo;
  • 同一个commandBuffer中,有两种情况

    • 不在RenderPass中的命令,即除去所有在vkCmdBeginRenderPassvkCmdEndRenderPass之间的命令,这些命令的提交顺序为按照在CPU上写入CommandBuffer时的顺序。
    • RenderPass中的命令,只定义在同一SubPass中的其他命令的提交顺序,这些命令的提交顺序也是按照在CPU上写入CommandBuffer时的顺序。如果几个命令在vkCmdBeginRenderPassvkCmdEndRenderPass之间,但是它们不在同一SubPass中,那么它们之间是不存在任何提交顺序的

vulkan还提供了一些隐式的同步机制,这些机制实际上都遵循了一个准则,那就是按照提交的先后顺序开始执行,但是不保证结束也是按照顺序完成

  • 所有的Action类命令(Draw、Transfer、Clear、Copy等)以及显示地使用同步机制的命令(这个在之后会介绍),这些命令在执行VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT时,会遵循提交顺序。即这些命令开始执行的顺序,是严格遵循提交顺序的。(但这并不意味着这些命令结束执行时的顺序会有什么约束,所有的这些命令,到底是哪个先结束,隐式同步并没有严格的规定,也就是说任何一个命令都有可能最先结束。)

  • 所有的设置状态类的命令(bind pipelines, descriptor sets, and buffers等),由于它们不需要在GPU上执行,它们只负责设置CPU上相应CommandBuffer的状态,所以它们的执行顺序,遵循它们在CPU上写入CommandBuffer时的顺序。

  • 所有的Draw类命令在处理Primitive时,首先遵循提交顺序,即先提交的Draw中的Primitive会先被处理。而在一个Draw内所提交的Primitive,会按照顶点和索引的下标顺序执行。

  • ImageLayout的转移,是通过ImageMemoryBarrier实现的(也是一种显式的同步原语),它们遵循提交顺序,即先提交的先转移。

参考资料

【Vulkan学习记录-基础篇-4】Vulkan中的同步机制_syddf-CSDN博客

Understanding Vulkan Synchronization - The Khronos Group Inc