多线程中的 Vulkan 队列同步

Vulkan Queue Synchronization in Multithreading

在我的应用程序中,"state" 和 "graphics" 必须在单独的线程中处理。因此,例如,"state" 线程只关心更新对象位置,而 "graphics" 线程只关心以图形方式输出当前状态。

为简单起见,假设整个状态数据都包含在单个 VkBuffer 中。 "state" 线程创建一个 Compute PipelineStorage BufferVkBuffer 支持,并定期 vkCmdDispatch 更新 VkBuffer.

同时,"graphics" 线程创建一个 Graphics Pipeline 和一个由相同 VkBuffer 支持的 Uniform Buffer,并定期绘制/vkQueuePresentKHR

显然必须有某种同步机制来防止 "graphics" 线程在 "state" 线程写入时从 VkBuffer 读取数据。

我唯一的想法是在两个线程中使用从vkQueueSubmitvkWaitForFences的主机互斥。

我想知道,是否有其他更有效的方法或者这种方法是否可行?

尝试使用信号量。它们用于仅在 GPU 上同步操作,这比在应用程序中等待并在之前的工作完全处理后再提交工作要优化得多。

当您提交工作时您可以提供一个信号量,该信号量会在该工作完成时发出信号。当您提交另一项工作时,您可以提供第二批应等待的相同信号量。第二批处理将在信号量收到信号时自动开始(此信号量也会自动取消信号并可以重复使用)。

(我认为使用与队列相关的信号量有一些限制。我稍后会在确认后更新答案,但它们应该足以满足您的目的。

[编辑] 使用信号量有一些限制,但它不应该影响你 - 当你在提交期间使用信号量作为等待信号量时,没有其他队列可以等待同一个信号量。 )

Vulkan 中也有一些事件可以用于类似的目的,但它们的使用稍微复杂一些。

如果您确实需要同步 GPU 和您的应用程序,请使用 fences。它们以与信号量类似的方式发出信号。但是您可以在应用程序端检查它们的状态,您需要手动取消它们的信号,然后才能再次使用。

[编辑]

我添加了一张图片,或多或少显示了我认为您应该做的事情。一个线程计算状态,并在每次提交时将一个信号量添加到列表的顶部(或如@NicolasBolas 所写的环形缓冲区)。该信号量在提交完成时发出信号(在 "compute" 批量提交期间在 pSignalSemaphores 中提供)。

第二个线程渲染你的场景。它管理自己的信号量列表,类似于计算线程。但是当你想要渲染东西时,你需要确保计算线程完成了计算。这就是为什么你需要获取最新的 "compute" 信号量并等待它(在 "render" 批量提交期间在 pWaitSemaphores 中提供它)。当您提交渲染命令时,计算线程无法启动和修改数据,因为它可能会影响渲染结果。所以计算线程也需要等到最近的渲染完成。这就是为什么计算线程也需要提供一个等待信号量(最近的 "rendering" 信号量)。

您只需要同步提交即可。当计算线程提交命令时,渲染线程无法启动,反之亦然。这就是为什么向列表中添加信号量(以及从列表中获取信号量)应该同步的原因。但这与 Vulkan 无关。可能一些互斥体会有所帮助(例如 C++-ish std::lock_guard<std::mutex>)。但是只有当您只有一个缓冲区时,这种同步才会成为问题。

另一件事是如何处理两个列表中的旧信号量。您不能直接检查它们的状态,也不能直接取消它们的信号。可以使用每次提交时提供的附加栅栏来检查信号量的状态。您不必等待它们,而是不时地检查给定的围栏是否发出信号,如果是,您可以销毁旧信号量(因为您不能从应用程序中取消信号量)或者您可以进行空提交,没有命令缓冲区,并将该信号量用作等待信号量。这样信号量将被解除信号,您可以重新使用它。但我不知道哪种解决方案更优:销毁旧的信号量并创建新的信号量,或者用空提交取消信号量。

当您只有一个缓冲区时,一个元素 list/ring 可能就足够了。但更理想的解决方案是使用某种类型的乒乓缓冲区集——您从一个缓冲区读取数据,但将结果存储在另一个缓冲区中。在下一步中,您交换它们。这就是为什么在上图中,信号量(环)列表可能有更多元素,具体取决于您的设置。列表中的独立缓冲区和信号量越多(当然是一些合理的数量),您将获得最佳性能,因为您减少了浪费在等待上的时间。但这会使您的代码复杂化,并且还可能增加延迟(渲染线程获取的数据比计算线程当前处理的数据稍旧)。因此,您可能需要在性能、代码复杂性和 渲染延迟.

之间取得平衡

如何做到这一点取决于两个因素:

  1. 是否要在与其对应的图形操作相同的队列上分派计算操作。

  2. 计算操作与其相应图形操作的比率。

#2 是最重要的部分。

即使它们是在单独的线程中生成的,至少必须知道图形操作是由特定的计算操作提供的(否则,图形线程如何知道从哪里读取数据? ).那么,你是怎么做到的?

归根结底,那部分与 Vulkan 无关。你需要使用一些线程间通信机制来允许图形线程询问,"which compute task's data should I be using?"

通常,这将通过让计算线程将它所做的每个计算操作添加到某种循环缓冲区(当然是线程安全的。并且非锁定)来完成。当图形线程决定从何处读取其数据时,它会向循环缓冲区询问最近添加的计算操作。

除了 "where to read its data from" 信息之外,这还将为图形线程提供适当的 Vulkan 同步原语,用于将其命令缓冲区与计算操作的 CB 同步。

如果计算和图形操作在同一个队列上分派,那么这就非常简单了。实际上不必有同步原语。只要图形 CB 在批次中计算 CB 之后发出,所有图形 CB 需要的是在前面有一个 vkCmdPipelineBarrier 等待来自计算阶段的所有内存操作。

srcStageMask 将是 STAGE_COMPUTE_SHADER_BIT,而 dstStageMask 几乎是所有内容(您可以缩小范围,但这并不重要,因为至少您的顶点着色器阶段将需要在那里)。

管道屏障中需要一个 VkMemoryBarriersrcAccessMask 将是 SHADER_WRITE_BIT,而 dstAccessMask 将是您打算阅读的内容。如果计算操作写入了一些顶点数据,则需要 VERTEX_ATTRIBUTE_READ_BIT。如果他们写了一些统一的缓冲数据,你需要UNIFORM_READ_BIT。等等。

如果您在单独的队列上分派这些操作,那么您需要一个实际的同步对象。

有几个问题:

  1. 您无法检测用户代码是否已发出 Vulkan 信号量信号。您也不能通过用户代码将信号量设置为未发出信号的状态。您也不能合理地提交一个批次,其中包含当前已发出信号且没有人在等待的信号量。你可以做后者,但它不会做正确的事情。

    简而言之,除非您确定某些进程将等待它,否则您永远不能提交发出信号量信号的批处理。

  2. 您不能发出等待信号量的批处理,除非发出信号的批处理是 "pending execution"。也就是说,在确定计算队列已提交其信号批处理之前,您的图形线程无法vkQueueSubmit其批处理。

所以你要做的就是这个。当图形队列去获取它的计算数据时,它必须向计算线程发送一个信号以将信号量添加到它的下一个提交调用。当图形线程提交其图形操作时,它会等待该信号量。

但是为了确保正确的顺序,图形线程在计算线程提交信号量信号操作之前不能提交它的操作。这需要某种形式的 CPU 同步操作。它可以像图形线程轮询计算线程设置的原子变量一样简单。