理解Vulkan中的同步操作(Pipeline Barriers,Events,Semaphores,Fences)

Number of views 135

原文出自khronos官网,有修改与完善

图形开发人员如果曾经使用过DirectX和OpenGL,可能会对诸如Vulkan这样接近硬件层面的GPU API的许多方面感到熟悉。但当我们深入探讨如何从Vulkan中获得最高性能时,就会发现DirectX和OpenGL驱动程序一直在幕后自动处理着许多驱动GPU的方方面面,现在我们能够显式地控制。

例如,Vulkan使开发人员能够在应用程序中对图形任务的协调和内存管理进行更明确的控制。这些是任何C/C++开发人员都应该能够应对的开发任务,尽管这可能会有一点点的学习难度——或者只是需要重新拾起一些长时间未用的技能。

本文的目标是帮助开发人员轻松理解,并且不被Vulkan中最复杂的方面之一——**同步(Synchronization)**所吓倒或混淆。我们将逐一介绍与同步相关的各个重要概念,并演示如何正确有效地使用它们。

同步是Vulkan API中至关重要但常常被误解的一部分。新的VK_KHR_synchronization2扩展包含了几项改进,旨在使Vulkan同步更易于使用,同时没有对下面描述的基本概念进行重大改变。
在博客中,我们将突出介绍Synchronization2引入的关键差异。

为什么同步很重要?

Vulkan 让我们对渲染过程有了更大的控制权,通过并行运行许多任务来最大化 CPU 和 GPU 的资源使用。与上一代API 给人的印象是顺序执行的不同,Vulkan 的行为很明确地就是并行的,并且为多线程设计。

例如,GPU 和 CPU 可以独立运行当前帧和下一帧的各种片元和顶点操作。通过明确哪些操作需要等待彼此,以及哪些操作不需要等待,Vulkan 可以以最高效率和最小等待时间渲染场景。

通过让 CPU 和 GPU 核心协同工作,并以正确的协调时间来避免资源闲置过长时间,我们可以从用户的设备系统中挤出最多的性能。关键在于确保任何并行任务只在必要时等待,并且只等待必要的时长。

这就是正确和有效的同步发挥作用的地方。

例如,我们需要让游戏的最终后处理着色器效果等待,直到当前帧完全渲染完毕,以避免渲染伪影或其他异常。Vulkan 的同步操作让我们能够将这些任务和依赖关系定义为渲染管道的一部分,以便它可以尽可能高效地处理工作。

要理解这如何工作,我们需要从两个层面来看待同步:单个队列内和多个队列之间。让我们先来看看队列内的同步。

Device Queue内的同步操作

Vulkan 让我们可以将命令缓冲区送入队列以处理图形操作。这一过程设计为对线程友好,因此我们可以通过任何 CPU 线程提交工作使用命令缓冲区,并最终插入到同一个 GPU 队列中。这使我们能够在 Vulkan 执行其命令的同时进行自己的多线程处理,Vulkan 的命令也常常是并行执行的,比如计算顶点或加载纹理,以最大化利用所有 CPU 核心。

请注意,我们的命令可以依赖于同一队列内其他命令的完成,而且它们不需要在同一个命令缓冲区内。命令也会按照它们被插入的确切顺序开始执行,但由于它们可以并行运行,所以不能保证命令会按照相同的顺序完成。

队列内的同步操作,确保这些命令正确等待其依赖项的完成,包括管道屏障(pipeline barriers)、事件(events)和子通道依赖(subpass dependencies)。让我们来了解一下如何使用这些同步相关的命令操作。

Pipeline Barriers(管道屏障)

管道屏障(Pipeline barriers)指定了要等待的数据或渲染管线的哪些阶段,以及在之前命令中指定的其他阶段完成前要阻塞哪些阶段。

请注意,这些屏障仅限于 GPU,这意味着我们无法从运行在 CPU 上的应用程序检查一个管道屏障何时被执行。如果我们需要向运行在 CPU 上的应用程序发送信号,可以通过使用其它类型的同步命令操作,如栅栏(fence)或事件(event)来实现,我们稍后会讨论这些同步操作。

有两种类型的屏障:

  • 执行屏障 (Execution Barriers): 确保特定的命令在其他命令之后执行。它们定义了在执行屏障前后命令之间的依赖关系,保证了命令执行的顺序性。例如,你可能想要确保所有的顶点着色器操作都完成后才开始片段着色器的操作。

  • 内存屏障 (Memory Barriers): 用于同步资源的访问,确保在屏障之前的资源的所有写入操作在屏障之后的读取操作可见。这包括不同类型的内存屏障,比如缓冲区内存屏障(Buffer Memory Barrier)、图像内存屏障(Image Memory Barrier)等,它们针对不同类型的数据结构和资源。

我们可以在一次调用中创建一个执行屏障,或者同时创建一个执行屏障和一个或多个类型的内存屏障。

以下是供参考的Pipeline Barriers函数,在下面我们将讨论其各个部分:

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);

Synchronization2 将屏障的管道阶段掩码存储在屏障结构体内,而不是作为单独的参数传递给 vkCmdPipelineBarrier。这一更改简化了资源跟踪。

Execution Barriers(执行屏障)

当我们想要控制命令的流程并使用管道屏障强制执行执行顺序时,可以在 Vulkan 执行某个动作之间插入一个屏障,并指定在继续之前需要完成的前置管道阶段。我们还可以指定哪些管道阶段应该在该屏障之后才开始执行。

这些选项是通过 vkCmdPipelineBarrier 的 srcStageMask 和 dstStageMask 参数设置的。由于它们是位标志,因此可以在这些掩码中指定多个阶段。

  • 哪些之前的阶段(操作)必须先完成:这叫做 srcStageMask
  • 哪些后续的阶段(操作)要等到屏障之后才开始:这叫做 dstStageMask

例子

假设你有两个任务:一个是写入数据到缓冲区,另一个是从同一个缓冲区读取数据。为了确保读取操作不会在写入操作完成前发生,你可以在写入和读取之间插入一个屏障。

srcStageMask 和 dstStageMask

  • srcStageMask:表示“等待这些阶段完成”。例如,如果你设置 srcStageMask 为顶点着色器阶段,这意味着所有顶点着色器的操作都必须完成,屏障才会生效。
  • dstStageMask:表示“这些阶段要等到屏障之后才开始”。例如,如果你设置 dstStageMask 为片段着色器阶段,这意味着片段着色器的操作会在屏障生效后才开始。

阶段扩展
Vulkan 会自动扩展这些阶段,以确保依赖关系正确:

  • 对于 srcStageMask,Vulkan 会包括逻辑上更早的阶段。比如,如果你指定了顶点着色器阶段,它也会等待顶点输入等更早的阶段。
  • 对于 dstStageMask,Vulkan 会包括逻辑上更晚的阶段。比如,如果你指定了片段着色器阶段,它也会阻塞颜色输出等更晚的阶段。

内存屏障(Memory Barriers)的特殊情况
需要注意的是,对于 内存屏障(用于同步对缓冲区或图像的访问),阶段扩展规则不适用。你需要明确指定哪些阶段需要同步。

Memory Barriers

为了提高性能,Vulkan 在 CPU 和 GPU 核心与相对较慢的主内存(RAM)之间使用了一系列缓存机制。这些缓存包括快速的 L1/L2 缓存,它们可以显著加快数据访问速度。

缓存问题

当一个核心写入内存(例如,写入渲染目标)时,更新可能仅存在于缓存中,而另一个准备使用该数据的核心还无法看到这些更新。内存屏障 是我们用来确保缓存被刷新、使之前命令的内存写入对后续命令可用的工具。此外,内存屏障还可以使缓存失效,以确保最新数据对执行后续命令的核心可见。

内存屏障的作用

除了为执行屏障指定的管道阶段掩码外,内存屏障还指定了要等待的内存访问类型和在指定管道阶段被阻塞的访问类型。每个内存屏障包含一个源访问掩码 (srcAccessMask) 和一个目标访问掩码 (dstAccessMask),用于指定:

  • 源访问:由源阶段在前置命令中执行的访问(通常是写入),必须在目标阶段的后续命令中可用且可见。
  • 目标访问:由目标阶段在后续命令中执行的访问,必须能够看到源阶段的访问结果。

与执行屏障不同,这些访问掩码只适用于在阶段掩码中明确设置的精确阶段,不会扩展到逻辑上更早或更晚的阶段。

内存屏障的三种类型

  • 全局内存屏障 (Global Memory Barrier)
    • 通过 pMemoryBarriers 参数添加。
    • 应用于所有内存对象。
    • 确保所有类型的内存访问在屏障前后正确同步。
  • 缓冲区内存屏障 (Buffer Memory Barrier)
    • 通过 pBufferMemoryBarriers 参数添加。
    • 仅应用于绑定到 VkBuffer 对象的设备内存。
    • 确保特定缓冲区的内存访问在屏障前后正确同步。
  • 图像内存屏障 (Image Memory Barrier):
    • 通过 pImageMemoryBarriers 参数添加。
    • 仅应用于绑定到 VkImage 对象的设备内存。
    • 不仅同步内存访问,还可以改变图像的布局(layout),这对于从一种用途过渡到另一种用途非常有用(例如,从渲染目标变为纹理采样)。
typedef struct VkMemoryBarrier {
   VkStructureType sType;
   const void* pNext;
   VkAccessFlags srcAccessMask;
   VkAccessFlags dstAccessMask;
} VkMemoryBarrier;

typedef struct VkBufferMemoryBarrier {
   VkStructureType sType;
   const void* pNext;
   VkAccessFlags srcAccessMask;
   VkAccessFlags dstAccessMask;
   uint32_t srcQueueFamilyIndex;
   uint32_t dstQueueFamilyIndex;
   VkBuffer buffer;
   VkDeviceSize offset;
   VkDeviceSize size;
} VkBufferMemoryBarrier;

typedef struct VkImageMemoryBarrier {
   VkStructureType sType;
   const void* pNext;
   VkAccessFlags srcAccessMask;
   VkAccessFlags dstAccessMask;
   VkImageLayout oldLayout;
   VkImageLayout newLayout;
   uint32_t srcQueueFamilyIndex;
   uint32_t dstQueueFamilyIndex;
   VkImage image;
   VkImageSubresourceRange subresourceRange;
} VkImageMemoryBarrier;

参数 srcQueueFamilyIndexdstQueueFamilyIndex 用于支持来自不同队列家族的多个设备队列(Device Queue)之间共享缓冲区或图像,这需要额外的同步内存访问的处理。对于以 VK_SHARING_MODE_EXCLUSIVE 模式创建的资源,必须在源队列和目标队列上执行队列所有权转移屏障(Queue Ownership Transfer Barriers)。这些屏障类似于普通的 vkCmdPipelineBarrier 操作,但屏障的源阶段和访问掩码部分提交给源队列,而目标阶段和访问掩码部分提交给目标队列。

队列所有权转移屏障

  • 源队列:在源队列中,你需要指定屏障的源阶段和访问掩码,确保所有相关的写入操作在转移所有权之前完成。
  • 目标队列:在目标队列中,你需要指定屏障的目标阶段和访问掩码,确保目标队列可以在所有权转移后安全地读取或写入资源。

使用 VK_SHARING_MODE_CONCURRENT

当你以 VK_SHARING_MODE_CONCURRENT 模式创建缓冲区和图像时,可以避免使用这些队列所有权转移屏障。然而,这种方式通常会导致性能下降,因为 Vulkan 需要在多个队列之间自动管理资源的同步,这会引入额外的开销。

执行与内存依赖

一个可能需要设置管道屏障的场景是当我们在一个计算着色器(compute shader)中写入纹理图像缓冲区,然后在片段着色器(fragment shader)中使用它。这种设置可能类似于 Vulkan 同步示例 Wiki 中的以下例子:

// 1. 在计算着色器中写入纹理图像
vkCmdDispatch(commandBuffer, groupCountX, groupCountY, groupCountZ);

// 2. 插入管道屏障,确保计算着色器的写入操作完成
VkImageMemoryBarrier imageMemoryBarrier = {};
imageMemoryBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
imageMemoryBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
imageMemoryBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
imageMemoryBarrier.oldLayout = VK_IMAGE_LAYOUT_GENERAL;  // 计算着色器使用的布局
imageMemoryBarrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;  // 片段着色器使用的布局
imageMemoryBarrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;  // 等待计算着色器的写入操作
imageMemoryBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;   // 阻塞片段着色器的读取操作
imageMemoryBarrier.image = textureImage;  // 要同步的图像
imageMemoryBarrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
imageMemoryBarrier.subresourceRange.baseMipLevel = 0;
imageMemoryBarrier.subresourceRange.levelCount = 1;
imageMemoryBarrier.subresourceRange.baseArrayLayer = 0;
imageMemoryBarrier.subresourceRange.layerCount = 1;

vkCmdPipelineBarrier(
    commandBuffer,
    VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,  // 源阶段:计算着色器
    VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, // 目标阶段:片段着色器
    0,
    0, nullptr,  // 内存屏障
    0, nullptr,  // 缓冲区内存屏障
    1, &imageMemoryBarrier  // 图像内存屏障
);

// 3. 开始绘制命令,片段着色器将使用更新后的纹理
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &descriptorSet, 0, nullptr);
vkCmdDrawIndexed(commandBuffer, indexCount, 1, 0, 0, 0);
  • 计算着色器写入:我们首先调用 vkCmdDispatch 来执行计算着色器,该着色器负责写入纹理图像。
  • 插入管道屏障:为了确保计算着色器的写入操作在片段着色器读取之前完成,我们插入了一个图像内存屏障 (VkImageMemoryBarrier)。这个屏障:
    • 将图像的布局从 VK_IMAGE_LAYOUT_GENERAL(适用于计算着色器的写入)转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL(适用于片段着色器的读取)。
    • 使用 srcAccessMask 等待计算着色器的写入操作完成。
    • 使用 dstAccessMask 阻塞片段着色器的读取操作,直到屏障生效。
    • 绘制命令:最后,我们绑定图形管线并执行绘制命令,片段着色器将使用更新后的纹理进行渲染。

通过这种方式,我们可以确保计算着色器和片段着色器之间的正确同步,避免竞态条件和其他并发问题。

Events

在 Vulkan 中,另一种同步工具是事件(event),它和管道屏障一样使用源阶段掩码(source stage masks)和目标阶段掩码(destination stage masks),在我们需要指定并运行并行计算时非常有用。事件与管道屏障之间的关键区别在于,事件屏障发生在两个部分:第一部分是使用 vkCmdSetEvent 设置一个事件,第二部分是使用 vkCmdWaitEvents 等待这个事件。如果之前通过 vkCmdSetEvent 或其他方式将事件设置为已信号状态,那么可以使用 vkCmdResetEvent 来将其恢复到未信号状态,以便在后续的任务中重新使用该事件。事件会同步发生在 vkCmdSetEvent 调用之前和 vkCmdWaitEvents 调用之后的执行以及内存访问;而位于 vkCmdSetEvent 和 vkCmdWaitEvents 之间的命令不受该事件的影响。虽然事件可以从 GPU 的命令缓冲区中设置,也可以从主机端设置,但这里我们只讨论 GPU 侧的事件设置。

事件的设置

通过调用 vkCmdSetEvent 并带有 stageMask 参数来设置一个事件,stageMask 标记了在信号事件之前需要等待的前序命令的阶段。vkCmdWaitEvents 的调用几乎与管道屏障参数相同,只是 srcStageMask 的含义有所改变,它变成了所有事件中 pEvents 的 stageMask 的联合。如果不使用内存屏障,vkCmdWaitEvents 会导致后续命令中的 dstStageMask 阶段的执行等待直到 pEvents 中的所有事件都发出信号。这在每个给定事件的 stageMask 阶段之间创建了一个执行屏障,即在 vkCmdSetEvent 之前的命令与后续命令中的 dstStageMask 阶段之间。

内存屏障

当存在时,可选的内存屏障功能与管道屏障非常相似。srcStageMask 和 srcAccessMask 结合定义了哪些内存访问应该完成、可用并且对那些匹配 dstStageMask 和 dstAccessMask 的后续命令可见,从而创建了一个内存屏障。然而,保证可用和可见的访问仅限于在每个 vkCmdSetEvent 之前的命令中匹配 stageMask 的访问,并且这些访问也必须包含在 vkCmdWaitEvents 的 srcStageMask 和 srcAccessMask 中。

就像管道屏障一样,对于执行屏障,阶段掩码会被扩展,但对于内存屏障则不会。

事件工作原理示例

让我们举例说明事件是如何工作的:

假设我们有一个场景,其中有两个并行的任务,任务A和任务B,它们分别由不同的着色器处理。我们希望确保任务A完成后,其结果可以被任务B安全地读取。为了实现这一点,我们可以使用事件来进行同步。

  • 任务A:首先执行任务A的命令,例如,执行一些计算操作或写入图像。
    • 设置事件:在任务A的命令之后,立即调用 vkCmdSetEvent 来设置一个事件,同时指定了 stageMask,以表明哪些阶段需要完成才能使事件有效。
  • 任务B:接下来是任务B的命令,它们可能依赖于任务A的结果。
    • 等待事件:在任务B的命令之前,调用 vkCmdWaitEvents 来等待前面设置的事件。我们提供相同的 dstStageMask 来确定任务B需要等待的阶段,以及任何必要的内存屏障来确保任务A的写入对任务B是可见的。

通过这种方式,我们可以确保任务A的执行和内存访问在任务B开始之前完成,从而避免数据竞争和其他并发问题。事件提供了一种灵活的方式,允许我们在命令流中插入精确的同步点,这对于复杂的渲染和计算流程来说是非常有用的。

// 1. 创建事件对象
VkEvent event;
VkEventCreateInfo eventCreateInfo = {};
eventCreateInfo.sType = VK_STRUCTURE_TYPE_EVENT_CREATE_INFO;
vkCreateEvent(device, &eventCreateInfo, nullptr, &event);

// 2. 在命令缓冲区中记录任务A的命令(例如,计算着色器)
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, computePipeline);
vkCmdDispatch(commandBuffer, groupCountX, groupCountY, groupCountZ);

// 3. 设置事件,表示在任务A的VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT完成后发出事件
VkPipelineStageFlags setEventStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
vkCmdSetEvent(commandBuffer, event, setEventStageMask);

// 4. 记录任务B的命令(例如,图形管线中的绘制命令)
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);
vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &descriptorSet, 0, nullptr);

// 5. 等待事件,确保任务A完成后再执行任务B
VkMemoryBarrier memoryBarrier = {};  // 可选:如果需要内存屏障
memoryBarrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
memoryBarrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;  // 等待计算着色器的写入操作
memoryBarrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;   // 阻塞片段着色器的读取操作

VkPipelineStageFlags waitEventStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
vkCmdWaitEvents(
    commandBuffer,
    1, &event,  // 等待的事件
    setEventStageMask,  // 源阶段掩码
    waitEventStageMask, // 目标阶段掩码
    1, &memoryBarrier,  // 内存屏障
    0, nullptr,         // 缓冲区内存屏障
    0, nullptr          // 图像内存屏障
);

// 6. 执行任务B的绘制命令
vkCmdDrawIndexed(commandBuffer, indexCount, 1, 0, 0, 0);

// 7. 提交命令缓冲区并等待完成
vkEndCommandBuffer(commandBuffer);
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
vkQueueSubmit(queue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(queue);

// 8. 销毁事件对象
vkDestroyEvent(device, event, nullptr);

下图是另一个示例:

image.png

事件用例如下所示:

// 三个调度命令,它们之间没有冲突的资源访问

vkCmdDispatch(1);  // 第一个计算任务
vkCmdDispatch(2);  // 第二个计算任务
vkCmdDispatch(3);  // 第三个计算任务

// 4、5 和 6 不与 1、2 和 3 共享资源
// 没有必要阻塞它们,因此设置一个事件以稍后等待

vkCmdSetEvent(A, srcStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);  // 设置事件 A,在计算着色器阶段完成后发出信号
vkCmdDispatch(4);  // 第四个计算任务
vkCmdDispatch(5);  // 第五个计算任务
vkCmdDispatch(6);  // 第六个计算任务

// 7 和 8 不使用与 4、5 和 6 相同的资源,因此使用另一个事件
vkCmdSetEvent(B, srcStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);  // 设置事件 B,在计算着色器阶段完成后发出信号

// 7 和 8 需要 1、2 和 3 的结果

// 因此我们通过等待事件 A 来确保 1、2 和 3 已完成
vkCmdWaitEvents(1, &A, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, nullptr, 0, nullptr, 0, nullptr);  // 等待事件 A,在计算着色器阶段之后继续
vkCmdDispatch(7);  // 第七个计算任务

vkCmdDispatch(8);  // 第八个计算任务

// 9 使用与 4、5 和 6 相同的资源,因此我们需要等待

// 同时假设 9 不依赖于 7 和 8 的结果
vkCmdWaitEvents(1, &B, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, nullptr, 0, nullptr, 0, nullptr);  // 等待事件 B,在计算着色器阶段之后继续

vkCmdDispatch(9);  // 第九个计算任务

在 Synchronization2 中,vkCmdSetEvent2KHR 需要传递管道屏障。这一更改通过在事件“设置”时调度,而不是在“等待”屏障信息可用时进行调度,从而提高了驱动程序的效率。

在 Vulkan 的 Synchronization2 扩展中,vkCmdSetEvent2KHR 函数的行为有所变化,它现在要求提供管道屏障(pipeline barriers)。这一变化的主要目的是为了提高驱动程序的效率。具体来说:

  • 传统方式:在旧的同步机制中,屏障信息通常是在 vkCmdWaitEvents 调用时提供的,这意味着驱动程序需要在等待事件时才能获取到屏障信息。这种方式可能会导致一些延迟,因为驱动程序无法提前知道哪些资源需要同步,直到实际等待事件发生时才能进行调度。

  • 新方式(Synchronization2):在 Synchronization2 中,vkCmdSetEvent2KHR 要求你在设置事件时就提供屏障信息。这使得驱动程序可以在事件“设置”时就调度相关的工作,而不需要等到“等待”事件时才获取屏障信息。这样可以更早地进行资源的同步和调度,减少了不必要的等待时间,从而提高了整体的执行效率。

为什么这样设计?

这种设计允许驱动程序更早地了解哪些资源需要同步,并且可以在事件设置时就开始准备相关的同步操作。这不仅减少了等待时间,还使得驱动程序能够更好地优化资源管理和任务调度,最终提升性能。

示例代码

// 设置事件并提供管道屏障
VkDependencyInfoKHR dependencyInfo = {};
dependencyInfo.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO_KHR;
dependencyInfo.dependencyFlags = 0;

VkMemoryBarrier2KHR memoryBarrier = {};
memoryBarrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2_KHR;
memoryBarrier.srcStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT_KHR;
memoryBarrier.srcAccessMask = VK_ACCESS_2_SHADER_WRITE_BIT_KHR;
memoryBarrier.dstStageMask = VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT_KHR;
memoryBarrier.dstAccessMask = VK_ACCESS_2_SHADER_READ_BIT_KHR;

dependencyInfo.memoryBarrierCount = 1;
dependencyInfo.pMemoryBarriers = &memoryBarrier;

vkCmdSetEvent2KHR(commandBuffer, event, &dependencyInfo);

在这个例子中,我们在调用 vkCmdSetEvent2KHR 时提供了管道屏障信息,确保在事件设置时就完成必要的同步操作,而不是等到后续的 vkCmdWaitEvents2KHR 调用时再处理。

Subpass Dependencies

在设备队列内进行同步的另一种方法是通过子通道依赖(subpass dependencies)。这些依赖类似于管道屏障,但专门用于表达渲染子通道(subpass)之间的依赖关系,以及渲染通道(render pass)实例内部命令与外部命令之间的依赖关系。

这些依赖可能会有些复杂,因为它们附带了许多限制。但在处理跨渲染通道(render pass)的数据时,例如渲染阴影或反射,或者当我们需要等待外部资源或事件时,它们可以非常有用。

多个(Device Queue)设备队列之间的同步

现在我们已经介绍了一些在单个设备队列内设置依赖关系的机制,接下来让我们看看如何在不同队列之间协调同步。

Vulkan API 提供了两种选项,每种都有不同的用途:信号量(semaphores)和栅栏(fences)。

需要注意的是,Vulkan 1.2 引入了时间线信号量(timeline semaphores),这是未来推荐使用的信号量新方法。然而,时间线信号量目前在移动平台上尚未广泛可用,因此我们将首先讨论原始的方法,然后再看看新的时间线信号量设计是如何能够替代这两种原始选项的。

信号量(Semaphores)

信号量是简单的信号标识符,用于指示一批命令何时被处理完成。在使用 vkQueueSubmit 提交队列时,我们可以将多个信号量作为参数传递。

理解并使用信号量的关键在于认识到它们仅用于 GPU 任务之间的同步,尤其是在多个队列之间,而不是用于 GPU 和 CPU 任务之间的同步。

如果多个命令在不同的核心和线程上忙于执行任务,信号量就像是一个公告,表明一组命令已经完成。信号量只有在批处理中的所有命令都完成后才会发出信号。它们提供了隐式的内存保证,因此我们可以在信号量之后访问任何内存,而无需担心在这之间是否需要添加内存屏障。

示例展示如何在一个Device Queue中提交信号量,另一个Device Queue中等待的例子:

  • 创建信号量并提交到队列
// 创建二进制信号量
VkSemaphoreCreateInfo semaphoreCreateInfo = {};
semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;

VkSemaphore semaphore;
VkResult result = vkCreateSemaphore(device, &semaphoreCreateInfo, nullptr, &semaphore);
if (result != VK_SUCCESS) {
    // 处理错误
}
// ...
// ...
// 提交命令缓冲区并设置信号量
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.waitSemaphoreCount = 0;  // 没有需要等待的信号量
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &semaphore;

VkResult result = vkQueueSubmit(queue, 1, &submitInfo, VK_NULL_HANDLE);
if (result != VK_SUCCESS) {
    // 处理错误
}
  • 在另一个队列中,我们可以等待这个信号量,确保前一个队列的任务已经完成。我们可以通过 VkSubmitInfo 结构体来指定要等待的信号量及其对应的管道阶段。
// 提交命令缓冲区并等待信号量
VkPipelineStageFlags waitStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &semaphore;
submitInfo.pWaitDstStageMask = &waitStageMask;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBufferB;
submitInfo.signalSemaphoreCount = 0;  // 不需要设置信号量

VkResult result = vkQueueSubmit(queue, 1, &submitInfo, VK_NULL_HANDLE);
if (result != VK_SUCCESS) {
    // 处理错误
}

Synchronization2 通过结构体数组传递信号量和命令缓冲区的数据,而不是将这些数据分散在多个结构体的独立数组中,以简化队列提交过程。

在 Vulkan 的 Synchronization2 扩展中,vkCmdSetEvent2KHR、vkCmdWaitEvents2KHR 以及 vkQueueSubmit2KHR 等函数的参数设计有所改进。具体来说,Synchronization2 使用结构体数组来传递信号量和命令缓冲区的相关信息,而不是像以前那样将这些信息分散在多个独立的数组中。这种设计的主要目的是为了简化队列提交的过程,使得代码更加简洁和易于管理。

栅栏(Fences)

栅栏的概念非常直观:虽然信号量是为了同步 GPU 任务而设计的,栅栏则是为了实现 GPU 和 CPU 之间的同步。

栅栏可以附加到队列提交中,应用程序可以通过 vkGetFenceStatus 检查栅栏的状态,或者使用 vkWaitForFences 等待队列中的命令完成。

栅栏提供了与信号量相同的隐式内存保证。例如,如果你想在交换缓冲区中呈现下一帧,可以使用栅栏来确定何时进行交换并开始渲染下一帧。

栅栏的作用

GPU 到 CPU 的同步:栅栏主要用于 GPU 和 CPU 之间的同步。当你提交一批命令到 GPU 队列时,你可以关联一个栅栏。当这批命令完成后,栅栏会变为已信号状态。CPU 可以通过检查栅栏的状态或等待栅栏来确保 GPU 已经完成了这些命令。

隐式内存保证:栅栏不仅用于同步命令的执行顺序,还提供了隐式的内存保证。这意味着当栅栏变为已信号状态时,Vulkan 会确保所有相关的内存操作(如写入和读取)已经正确完成。因此,你可以在栅栏之后安全地访问相关内存,而无需手动添加内存屏障。

栅栏的使用方式

检查栅栏状态:你可以使用 vkGetFenceStatus 来检查栅栏是否已经变为已信号状态。这允许你在不阻塞的情况下轮询栅栏的状态,适合于需要非阻塞检查的场景。

VkResult result = vkGetFenceStatus(device, fence);
if (result == VK_SUCCESS) {
    // 栅栏已信号,表示 GPU 已完成任务
}

等待栅栏:你可以使用 vkWaitForFences 来等待一个或多个栅栏变为已信号状态。这会导致 CPU 阻塞,直到指定的栅栏完成。你可以指定超时时间,以避免无限期等待。

VkResult result = vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX);
if (result == VK_SUCCESS) {
    // 栅栏已信号,表示 GPU 已完成任务
}

栅栏在帧渲染中的应用

栅栏在多帧渲染中特别有用。假设你正在使用交换链(swap chain)来呈现每一帧。为了确保当前帧的渲染已经完成,你可以为每个帧分配一个栅栏,并在提交命令时将栅栏与命令缓冲区一起传递。这样,你可以在提交下一帧之前检查或等待当前帧的栅栏,确保不会在 GPU 尚未完成当前帧的渲染时就开始渲染下一帧。

// 创建栅栏
VkFenceCreateInfo fenceCreateInfo = {};
fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
VkFence fence;
vkCreateFence(device, &fenceCreateInfo, nullptr, &fence);

// 提交命令缓冲区并关联栅栏
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
vkQueueSubmit(queue, 1, &submitInfo, fence);

// 等待栅栏信号,确保 GPU 完成当前帧的渲染
vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX);

// 重置栅栏,以便可以再次使用
vkResetFences(device, 1, &fence);

// 进行交换链的交换,准备呈现下一帧
vkQueuePresentKHR(queue, &presentInfo);

在这个例子中:

栅栏: 用于确保 GPU 完成了当前帧的渲染。
等待栅栏: 使得 CPU 可以在提交下一帧之前等待当前帧的完成。
重置栅栏: 使得栅栏可以再次用于后续的帧。

时间线信号量(Timeline Semaphores)

现在我们来介绍一下 Vulkan 1.2 API 的新特性——时间线信号量。

时间线信号量的新方法带来了多种优势,因为它非常灵活,并且作为信号量和栅栏的超集,允许在 GPU 和 CPU 之间双向传递信号。不仅可以在 CPU 上的应用程序中等待信号量,还可以从 CPU 向 GPU 发送信号量!而传统的栅栏仅限于粗粒度的队列提交级别,时间线信号量则提供了更细粒度的控制,增加了灵活性。

时间线信号量的工作原理非常巧妙:它使用一个整数计数器,每个信号量在完成时会递增这个计数器,形成一个信号时间线。

你可以把它想象成体育场观众一起组织的“人浪”。每一排的人依次站起来再坐下,将动作传递给下一个人,而不需要特别告诉旁边的人该做什么。每个人已经知道自己在体育场中的位置(即时间线),当“人浪”接近时,他们就知道轮到自己了。这确实很酷。

总结

1.单个队列内的同步(Device Queue内)

  • Pipeline Barriers(管道屏障)
    • 执行屏障 (Execution Barriers):确保特定命令在其他命令之后执行。
    • 内存屏障 (Memory Barriers):同步资源的访问,确保在屏障之前的资源的所有写入操作在屏障之后的读取操作可见。
      • 全局内存屏障 (Global Memory Barrier)
      • 缓冲区内存屏障 (Buffer Memory Barrier)
      • 图像内存屏障 (Image Memory Barrier)
  • Events(事件)
    • vkCmdSetEvent:设置一个事件,表明哪些阶段需要完成才能使事件有效。
    • vkCmdWaitEvents:等待一个或多个事件,确保指定的阶段在事件发出信号后才执行。
  • Subpass Dependencies(子通道依赖)
    • 专门用于表达渲染子通道(subpass)之间的依赖关系。

2.多个队列之间的同步(Device Queue间)

  • Semaphores(信号量)
    • vkCreateSemaphore:创建信号量对象。
    • vkQueueSubmit:提交命令缓冲区时,使用信号量来指示一批命令何时被处理完成。
    • vkWaitSemaphore:等待信号量,确保前一个队列的任务已经完成。
  • Fences(栅栏)
    • vkCreateFence:创建栅栏对象。
    • vkQueueSubmit:将栅栏与命令缓冲区一起提交,用于GPU和CPU之间的同步。
    • vkGetFenceStatus:检查栅栏的状态,看是否已经变为已信号状态。
    • vkWaitForFences:等待一个或多个栅栏变为已信号状态。
  • Timeline Semaphores(时间线信号量)
    • vkCreateSemaphore(用于创建时间线信号量):创建时间线信号量对象,使用整数计数器来跟踪信号量的状态。
    • vkSignalSemaphore:从CPU或GPU发出信号量。
    • vkWaitSemaphore:在CPU上等待时间线信号量。
0 Answers