RPS 教程第四部分 – RPS SDK 多线程指南

首次发布时间:
最后更新:
Noah Cabral's avatar
Noah Cabral

引言

本教程将指导读者了解如何从多个线程记录图命令,以及如何在多线程环境中从回调中记录 API 命令。与第一部分教程类似,这里提供了完整应用程序的完整源代码。应用程序源代码是从第一部分的源代码修改而来,可以在此处找到:rps_multithreading.cpp。除非另有说明,本教程中的所有代码片段都属于耦合源代码。

概述

设置

为了演示 MT 用例,我们首先需要一个有此需求的应用程序。我们将修改我们的三角形示例以渲染更多的三角形!

在我们的 RPSL 程序中,我们将定义一个由几个 GeometryPass 节点组成的图。每个通道将向后缓冲区的某个视口渲染 1024 个三角形。视口将使用 HLSL SV_Viewport 语义进行设置。

我们新的 RPSL 着色器如下所示:

graphics node GeometryPass(rtv renderTarget
: SV_Target0, float oneOverAspectRatio, float timeInSeconds, RpsViewport viewport
: SV_Viewport);
export void main([readonly(present)] texture backbuffer, float timeInSeconds)
{
// ...
uint32_t numPasses = 10;
uint32_t triStride = floor(sqrt(numPasses));
for (uint32_t i = 0; i < numPasses; i++)
{
float sX = texDesc.Width / float(triStride);
float sY = texDesc.Height / float(triStride);
float x = (i % triStride) * sX;
float y = (i / triStride) * sY;
RpsViewport v = viewport(x, y, sX, sY);
GeometryPass(backbuffer, oneOverAspectRatio, timeInSeconds, v);
}
}

当然,渲染 X 个三角形的部分发生在宿主应用程序的回调中。这部分以及其他必要的更改已在宿主应用程序端进行了修改,以支持新的渲染图。为简洁起见,此处省略这些更改。

从回调中扩展

RPS 拥有其 API 的特定表面,用于协助从回调中进行多线程记录。这种记录如何实现的概念模型取决于所使用的特定运行时后端。

我们将从 DirectX 12 开始。

DirectX 12

在这里,概念模型是每个工作线程都将获得自己的命令列表。我们将按顺序将这些列表提交给 GPU,就好像它们是单个命令列表一样。

回想一下,在启动图记录之前,我们会向 RPS 提供一个命令列表,该命令列表将在节点回调中返回给我们。这与 RPS 插入障碍的命令列表是同一个。

假设我们天真地实现了我们刚刚描述的概念模型。以下是一些伪代码,以确保我们都在同一页面上:

def node_callback(pContext):
cmdList = pContext.hCommandBuffer
for i in range(threads_to_launch):
thread_cmd_list = acquire_new_cmd_list()
job = lambda thread_cmd_list : thread_cmd_list.draw_foo_at_the_bar()
enqueue_job(job)

通过这种天真的方法,我们将观察到以下图表描述的行为,即在调用 rpsRenderGraphRecordCommands 时,“主”命令列表之后线性提交的所有节点回调中排队的工作。我们确保 RPS 在后台记录的每个命令都发生在我们的应用程序在 rpsRenderGraphRecordCommands 调用时记录的所有 API 命令之前。这是非常错误的!

cmdList passed at graph record time: Worker thread command lists:
|----------------------------------------| |-------| |-------| |-------|
| e.g. all barriers are inserted here. |------>| |---->| |---->| |----> ...
|----------------------------------------| |-------| |-------| |-------|

因此,我们提供了一种方法来覆盖当前正在使用的命令缓冲区,并用一个新的命令缓冲区替换它。这通过对 rpsCmdSetCommandBuffer 的 API 调用来完成。RPS 提供此功能是为了让宿主应用程序可以在节点回调结束时覆盖缓冲区,从而使后续的节点回调继续使用不同的缓冲区。这允许宿主应用程序在队列提交时在工作线程列表之间进行仲裁。

请记住,RPS 不跟踪历史命令缓冲区。宿主应用程序负责跟踪和确保提交的缓冲区以正确的顺序排列。

扩展我们的示例应用程序

因此,我们刚才提到 rpsCmdSetCommandBuffer 的调用应该在回调结束时进行。这足以勾勒出我们如何完成多线程记录的通用图景。

我们现在将修改我们的示例应用程序以实现完整的功能。

对于我们的 DX12 MT 用例,我们希望所有命令列表都具有相同的管道状态设置 – 即我们之前设置的 preamble 状态。

我们如何让 RPS 为我们所有的工作线程命令列表执行此操作?

这是通过 rpsCmdBeginRenderPass 实现的。此调用与节点 preamble 的作用完全相同(在 第三部分教程 中进行了说明)。关键在于我们可以随时调用它,并且显式调用与隐式调用之间的区别在于,节点绑定时设置的标志不适用于 rpsCmdBeginRenderPass。这很重要,因为我们希望设置 RPS_CMD_CALLBACK_CUSTOM_ALL 以跳过自动 preamble,但我们不希望此行为适用于显式 preamble。

让我们在应用程序中进行这些更改。

首先,我们设置 custom all 标志以跳过默认 preamble – 我们只想在工作线程命令缓冲区上设置图形状态(绑定渲染目标等),

virtual void OnInit(ID3D12GraphicsCommandList* pInitCmdList,
std::vector<ComPtr<ID3D12Object>>& tempResources) override
{
// ...
// Bind nodes.
AssertIfRpsFailed(rpsProgramBindNode(rpsRenderGraphGetMainEntry(m_rpsRenderGraph),
"GeometryPass",
&RpsMultithreading::GeometryPass,
this,
RPS_CMD_CALLBACK_CUSTOM_ALL));
// ...
}

接下来,我们希望确保在每个命令缓冲区上调用 rpsCmdBeginRenderPass。但是,我们还没有准备好这样做。考虑函数的签名,

RpsResult rpsCmdBeginRenderPass(const RpsCmdCallbackContext* pContext, RpsRuntimeRenderPassFlags flags);

第一个参数是回调上下文。回想一下,此上下文包含在我们启动渲染图录制时传递的命令缓冲区的句柄。如果我们使用回调接收的上下文调用此函数,则图形状态将在主命令缓冲区上设置,而不是工作线程命令缓冲区上。

如何让此函数记录到任意命令缓冲区?

引入另一个 API 调用:rpsCmdCloneContext。您将当前回调接收的上下文提供给此调用,它会克隆该上下文。同时,您要确保提供一个新的命令缓冲区句柄。每个上下文都映射到一个命令缓冲区。

因此,我们现在可以实际调用 rpsCmdBeginRenderPass

void GeometryPass(const RpsCmdCallbackContext* pContext,
rps::UnusedArg u0,
float oneOverAspectRatio,
float timeInSeconds)
{
//...
for (uint32_t i = 0; i < renderJobs; i++)
{
// auto hNewCmdBuf = AcquireNewCommandBuffer( ... );
const RpsCmdCallbackContext* pLocalContext = {};
{
std::lock_guard<std::mutex> lock(m_cmdListsMutex);
AssertIfRpsFailed(rpsCmdCloneContext(pContext, hNewCmdBuf, &pLocalContext));
}
// ...
auto job = [=]() {
RpsCmdRenderPassBeginInfo beginInfo = {};
RpsRuntimeRenderPassFlags rpFlags = RPS_RUNTIME_RENDER_PASS_FLAG_NONE;
rpFlags |= (i != 0) ? RPS_RUNTIME_RENDER_PASS_RESUMING : 0;
rpFlags |= (i != (renderJobs - 1)) ? RPS_RUNTIME_RENDER_PASS_SUSPENDING : 0;
beginInfo.flags = rpFlags;
AssertIfRpsFailed(rpsCmdBeginRenderPass(pLocalContext, &beginInfo));
// render ...
AssertIfRpsFailed(rpsCmdEndRenderPass(pLocalContext));
};
// launch job.
}
// ...
}

上面的代码片段中还有两个尚未解释的额外概念。首先,在调用 rpsCmdCloneContext 时使用了一个锁。克隆的上下文是 RPS SDK 管理的数据结构,因此此调用会发生分配。如果我们尝试同时从两个线程调用 clone context,将导致未定义行为。

第二个概念是传递给 rpsCmdBeginRenderPass 的神秘的渲染通道恢复和暂停标志。这些标志用于指示渲染通道正在暂停,并将使用另一个渲染通道恢复。如果渲染通道正在恢复,任何原本会自动发生的清除操作将不会发生。同样,如果渲染通道正在暂停,任何解析操作都不会自动发生。

好了,我们几乎完成了!我们只需要调用 rpsCmdSetCommandBuffer 来覆盖要用于后续节点回调录制的缓冲区。

void GeometryPass(const RpsCmdCallbackContext* pContext,
rps::UnusedArg u0,
float oneOverAspectRatio,
float timeInSeconds)
{
//...
for (uint32_t i = 0; i < renderJobs; i++)
{
// ...
}
// auto hNewCmdBuf = AcquireNewCommandBuffer( ... );
AssertIfRpsFailed(rpsCmdSetCommandBuffer(pContext, hNewCmdBuf));
}

最后,请记住 RPS 不跟踪历史命令缓冲区。因此,对于我们的示例应用程序,已经创建了一个特殊的辅助函数 AcquireNewCommandBuffer。这用于构建命令缓冲区的链接列表结构,以便在队列提交时我们知道如何按正确的顺序提交它们。

Vulkan

在 Vulkan 中,当前支持的多线程录制路径是为每个工作线程使用辅助命令缓冲区。为此,无需调用 rpsCmdSetCommandBuffer,因为我们只需在主命令缓冲区中调用 vkCmdExecuteCommands

VK 中回调中的 MT 与 DX12 中的相同,但有一些额外的更改,

  • 使用 RPS_RUNTIME_RENDER_PASS_EXECUTE_SECONDARY_COMMAND_BUFFERS 标志在主命令缓冲区上开始渲染通道。

  • 使用 RPS_RUNTIME_RENDER_PASS_SECONDARY_COMMAND_BUFFER 标志在每个辅助缓冲区上开始渲染通道。

  • 不再需要使用暂停/恢复渲染通道标志。

下面是一个示例,希望能让事情具体化:

void DrawGeometry(const RpsCmdCallbackContext* pContext)
{
// Begin RP on primary cmdBuf with expected contents as secondary command buffers.
RpsCmdRenderPassBeginInfo passBeginInfo = {};
passBeginInfo.flags = RPS_RUNTIME_RENDER_PASS_EXECUTE_SECONDARY_COMMAND_BUFFERS;
rpsCmdBeginRenderPass(pContext, passBeginInfo);
VkCommandBuffer vkCmdBufs[MAX_THREADS];
for (uint32_t i = 0; i < numThreads; i++)
{
// retrieve a new command buffer (hNewCmdBuf).
vkCmdBufs[i] = hNewCmdBuf;
const RpsCmdCallbackContext* pLocalContext = {};
{
std::lock_guard<std::mutex> lock(m_cmdListMutex);
rpsCmdCloneContext(pContext, rpsVKCommandBufferToHandle(hNewCmdBuf), &pLocalContext);
}
auto job = [=]()
{
// Begin RP on secondary cmdBuf. Let RPS know this pass is on a secondary command buffer.
// This communicates to RPS to not call vkCmdBeginRenderPass. RP is inherited.
RpsCmdRenderPassBeginInfo passBeginInfo = {};
passBeginInfo.flags = RPS_RUNTIME_RENDER_PASS_SECONDARY_COMMAND_BUFFER;
rpsCmdBeginRenderPass(pLocalContext, passBeginInfo);
// record work.
rpsCmdEndRenderPass(pLocalContext);
};
// launch job.
}
// wait for jobs.
VkCommandBuffer cmdBufPrimary = rpsVKCommandBufferFromHandle(pContext->hCommandBuffer);
vkCmdExecuteCommands(cmdBufPrimary, numThreads, vkCmdBufs);
rpsCmdEndRenderPass(pContext);
}

请注意,上面的代码片段并非本教程相关源代码的一部分。有关使用 Vulkan 进行多线程录制的实际示例,请参阅 test_multithreading_vk.cpp

从多个线程记录渲染图

到目前为止,我们一直从单个节点回调的角度讨论多线程录制。然而,对整个图本身进行多线程录制也是可能的。

在调用 rpsRenderGraphRecordCommands 时,通过 cmdBeginIndexnumCmds 提供线性命令流中的一系列命令。因此,这种情况下的多线程录制意味着将整个命令流分割成一组子范围,并将每个子范围提供给每个工作线程。并且由于每个命令回调上下文都与一个命令缓冲区相关联,因此我们需要为每个记录信息结构分配一个唯一的缓冲区。

代码看起来是什么样的?

virtual void OnRender(uint32_t frameIndex) override
{
RpsRenderGraphBatchLayout batchLayout = {};
AssertIfRpsFailed(rpsRenderGraphGetBatchLayout(m_rpsRenderGraph, &batchLayout));
const uint32_t batchCmdEnd = batch.cmdBegin + batch.numCmds;
const uint32_t cmdsPerThread = DIV_CEIL(batch.numCmds, m_graphThreadsToLaunch);
const uint32_t numThreadsActual = DIV_CEIL(batch.numCmds, cmdsPerThread);
uint32_t cmdBegin = 0;
ScopedThreadLauncher stl;
for (uint32_t threadIdx = 0; threadIdx < numThreadsActual; threadIdx++)
{
const uint32_t cmdEnd = std::min(batchCmdEnd, cmdBegin + cmdsPerThread);
auto job = [=]() {
auto buffer = buffers[threadIdx];
RpsRenderGraphRecordCommandInfo recordInfo = {};
recordInfo.hCmdBuffer = buffer;
recordInfo.cmdBeginIndex = cmdBegin;
recordInfo.numCmds = cmdEnd - cmdBegin;
AssertIfRpsFailed(rpsRenderGraphRecordCommands(m_rpsRenderGraph, &recordInfo));
};
stl.launchJob(job);
cmdBegin = cmdEnd;
}
stl.waitForAllJobs();
// collect all command buffers recorded to and order them appropriately before submitting to API queue ...
}

请注意,上面的代码片段已从本教程的相关源代码中修改。这样做是为了简化。再次强调,完整源代码可以在此处找到:rps_multithreading.cpp

Noah Cabral's avatar

Noah Cabral

Noah Cabral 是 AMD 核心技术组的图形研发实习生。他对计算机科学和计算机图形学的一切都怀有长久的热情,并希望为最先进的实时图形的发展做出贡献。
© . This site is unofficial and not affiliated with AMD.