大家好,接下来将为大家介绍Vulkan 同步机制 Event Barrier 。
接着上一节的内容Vulkan 同步机制 Fence Semaphore,继续介绍Vulkan 同步机制 Event Barrier。
1、Barrier
Barrier是同一个queue中的command,或者同一个subpass中的command所明确指定的依赖关系。barrier的中文释义一般叫栅栏或者屏障,我们可以想象一下有一大串的command乱序执行(实际上可能是顺序开始,乱序结束),barrier就是在中间树立一道栅栏,要求栅栏前后保持一定的顺序。
vkCmdPipelineBarrier(3) API可以用于创建一个Pipeline中的Barrier。注意这个API与fence/semaphore的不同,这个API的前缀是vkCmd
,这意味着这是一个向command buffer中记录命令的API:
void 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);
第一个参数command buffer
就是这个命令将要被记录的command buffer。pipe line barrier涉及到两个同步范围,这两个同步范围所处的stage分别由srcStageMask
和dstStageMask
指明,注意着两个都是mask,所以每一个都可以设置多个阶段。关于各个stage后续有机会我再介绍。dependencyFlags
是个比较高级的使用方法,我们这里不多介绍,后续有机会我会开专门的文章来尝试理解。
然后就是由vulkan API一贯味道的三个数组了 ,分别是memoryBarrier
bufferMemoryBarrier以及最后的
ImageMemoryBarrier。当着三个数组都为空的时候,将会在当前执行环境创建一个Execution Barrier,否则,则创建一个Memory Barrier。
Execution Barrier
Execution Barrier就是简单的执行屏障。我们这里举一个来自于khronos官方的例子。
vkCmdDispatch(...);
vkCmdPipelineBarrier(
...
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // srcStageMask
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // dstStageMask
...);
vkCmdDispatch(...);
两个dispatch中,如果涉及到执行的先后顺序,就需要一个execution barrier。如果没有这个barrier,那么这两个dispatch先后顺序是无法预测的。很可能的情况是,第一个dispatch开始后,第二个dispatch也马上开始。至于谁先结束,无法预测。
Execution Barrier可以有效控制queue中的command或者subpass中的command的执行顺序。
这个示例使用了vulkan中的compute path,毕竟这条路比graphic那条路简单了许多。对于compute path而言,可能的stage只有top, indirect, compute/transfer, bottom。如果是graphic path,可能的stage就多了去了。后续有机会我们会再介绍这个。现在只有理解个大概就可以了。
缺点
Execution Barrier只保证了执行的顺序,对于存储修改的顺序,execution barrier无能为力。
例如,在典型的Read-After-Write的问题中,我们让一个dispatch写入一个resource,随后在下一个dispatch中读取这个resource。如果没有execution barrier,就很可能会出现第一个dispatch正在执行的时候,第二个dispatch也已经开始执行的情况了。这样一来,第二个dispatch所读取的resource的内容就无法保证了。
为了保证这两个dispatch之间的顺序,我们可以在中间插入一个barrier。最开始,我们简单滴插入一个execution barrier。这样一来,只有当第一个dispatch执行完成后,第二个dispatch才能开始执行。execution barrier可以正确保证执行的顺序。然而,这就够了吗?
对于理想模型而言,第一个dispatch执行完毕,更新resource的内容。等到第二个dispatch开始的时候,第二个dispatch可以立即获取resource中更新后的结果。然而,事实上,由于现代GPU同样采取了复杂的缓存控制机制,这个理想的模型是不存在的。一种可能的结果是,第一个dispatch执行完毕后, resource最新的内容被缓存到了某一级cache中。不幸的是,第二个dispatch开始执行的时候,这个cache对第二个dispatch不可见(例如,两个dispatch被分派到了不同的执行单元中)。尽管从顺序上,这的确保证了第一个dispatch先执行,然后才是第二个dispatch,但是我们仍然无法保证第二个dispatch能够看到第一个dispatch更新后的结果。
究其原因,就是因为execution barrier只能保证执行上的顺序,而无法保证对于存储操作的顺序。在多核CPU中我们同样也能看到这样的问题存在,有兴趣的读者可以去翻一下我的专栏,了解多核CPU同步原语的背后思想。为了解决execution barrier无法控制存储的缺点,我们需要引入新的barrier,即memory barrier。
Memory Barrier
memory barrier是一种更严格意义上的barrier。一个memory barrier同时兼备了execution barrier语义。memory barrier的引入主要是为了解决execution barrier中,无法有效控制缓存的缺点。我们当然可以选择让所有的execution barrier天然具有memory barrier语义,这样一来,所有的execution barrier实际上都变成了memory barrier。但是粗暴的设计无法让我们精确地获取所需要的极致性能。
例如,我们有一个Write-After-Read的情况。第一个dispatch先读取一个resource的情况,接下来是第二个dispatch来更新这个resource。如果设计上要求所有的execution barrier都需要具有缓存同步的语义,那么情况就变成了这个样子。第一个dispatch读取resource,然后flush修改到一个合适的部分(可能是GPU全局可见的某一级cache,或者是global memory),然而第二个dispatch才能开始执行。这个额外增加的flush动作,实际上没有flush任何数据,因为第一个dispatch对resource的访问时只读访问,没有修改任何数据。这里,我们只需要一个不带有缓存同步的barrie就可以了。
在Pipeline Barrier API中,可以指定三个数组。这三个数组,分别定义了不同类型的memory barrier:
- 全局memory barrier
- buffer上的memory barrier
- image上的memory barrier
全局memory barrier只有src的访问mask和dst的访问mask,因此作用于当前所有的resource。需要具体操纵某个resource的时候,根据resource的类型,分别使用buffer或者image的memory barrier。
2、Event
Event用于同步提交到同一队列的不同命令,或者同步CPU和队列。它同样也具有两种状态——signaled和unsignaled,与Fence不同的是,它的状态改变既可以在CPU上完成,也可以在GPU上完成,并且它是一种细粒度的同步机制。注意:Event不能用于不同队列的命令之间的同步。
在CPU上,可以调用vkSetEvent来使一个Event变成Signaled的状态;可以调用vkResetEvent来使一个Event变成Unsignaled的状态;可以调用vkGetEventStatus来获取一个Event的当前状态,可以利用这个当前状态来对CPU进行阻塞。
而在GPU上:
1.可以通过vkCmdSetEvent命令来使得一个Event变成Signaled状态,此时该命令附带了一个操作执行同步:根据提交顺序,所有在该命令之前的所有命令都必须在此次把Event设置Signaled状态之前完成。
2.可以通过vkCmdResetEvent命令来使得一个Event变成Unsignaled状态,此时该命令附带了一个操作执行同步:根据提交顺序,所有在该命令之前的所有命令都必须在此次把Event设置Unsignaled状态之前完成。
这里有一点非常需要注意:vkCmdSetEvent和vkCmdResetEvent不能够在一个RenderPass内被执行。
创建一个Event
一个event,基本上和semaphore或者fence一样,由host创建,API为vkCreateEvent(3):
VkResult vkCreateEvent(
VkDevice device,
const VkEventCreateInfo* pCreateInfo,
const VkAllocationCallbacks* pAllocator,
VkEvent* pEvent);
创建event基本不需要额外的信息,并且在host端使用event也非常简单明了,比较复杂的是如何在device端使用event。
Event支持的操作
device上可以使用vkCmdSetEvent(3)触发(set)一个event,可以使用vkCmdResetEvent(3)重置一个event,还可以使用vkCmdWaitEvents(3)等待一个event被触发。其中,WaitEvents有着和barrier极为类似的设计,可以支持缓存控制。
host上可以使用vkSetEvent(3)触发event,也可以使用vkResetEvent(3)重置一个Event。如果host上需要等待event,需要使用vkGetEventStatus(3)来查询状态。