LiquidVR™
LiquidVR™ 提供了一个基于 Direct3D 11 的接口,应用程序可以通过该接口访问以下 GPU 功能,而无论系统是否安装了 VR 设备。
每个工作图节点都可以接受输入记录,作为该节点每次调用的参数。作为一个非常简单的例子,请看下面的 HLSL 源代码:
RWByteAddressBuffer Output : register(u0);
struct InputRecord{ uint index;};
[Shader("node")][NodeLaunch("broadcasting")][NodeDispatchGrid(1, 1, 1)][NumThreads(1,1,1)]void BroadcastNode(DispatchNodeInputRecord<InputRecord> inputData){ Output.Store(inputData.Get().index * 4, 1);}注意引入了一个新的语义 DispatchNodeInputRecord<>,它指示着色器编译器从(由驱动程序维护的)输入记录队列中检索此值。为了在提供的“Hello World”示例中使用此语义,您还需要提供至少一个输入记录。有几种选择,但一种直接的方法可能是修改 DispatchWorkGraphAndReadResults() 中填充的 D3D12_DISPATCH_GRAPH_DESC,如下所示:
struct InputRecord { UINT index; };
InputRecord record = { 0 };
# // dispatch work graph# D3D12_DISPATCH_GRAPH_DESC DispatchGraphDesc = { };# DispatchGraphDesc.Mode = D3D12_DISPATCH_MODE_NODE_CPU_INPUT;# DispatchGraphDesc.NodeCPUInput = { };# DispatchGraphDesc.NodeCPUInput.EntrypointIndex = 0;# DispatchGraphDesc.NodeCPUInput.NumRecords = 1; DispatchGraphDesc.NodeCPUInput.pRecords = &record; DispatchGraphDesc.NodeCPUInput.RecordStrideInBytes = sizeof(InputRecord);在声明输入记录的内容时,可以指定某些语义。值得注意的是,SV_DispatchGrid 允许您动态指定给定 [NodeLaunch("broadcasting")] 节点的调度所需的线程组数量。请考虑以下 HLSL 源代码:
RWByteAddressBuffer Output : register(u0);
struct InputRecord{ uint3 DispatchGrid : SV_DispatchGrid; uint index;};
[Shader("node")][NodeLaunch("broadcasting")][NodeMaxDispatchGrid(64, 64, 1)][NumThreads(1, 1, 1)]void BroadcastNode(DispatchNodeInputRecord<InputRecord> inputData){ Output.InterlockedAdd(inputData.Get().index * 4, 1);}注意 [NodeDispatchGrid(1, 1, 1)] 属性是如何被 [NodeMaxDispatchGrid(64, 64, 1)] 取代的。这是因为网格大小本身现在是 DispatchNodeInputRecord<> 的一部分。您可以将上面的代码与 DispatchWorkGraphAndReadResults() 中类似以下的声明配对,假设 输入记录 部分中概述的修改已生效:
struct InputRecord{ UINT DispatchGrid[3]; UINT index;};
InputRecord record = { { 2, 1, 1 }, 0 };此范例有效地复制了调度计算着色器的过程。它可能非常有用,尤其是在将现有算法移植到工作图形式时。
注意:工作图函数的其他输入参数声明也可以被指定,并用图形程序员在传统计算中已经熟悉的语义进行标记,例如
SV_GroupIndex。
虽然输入记录是从 CPU 或先前的工作负载将数据传输到工作图节点的好方法,但它们也可以用于在工作图节点之间传输数据。这是通过将输入记录与输出记录结合来实现的。请考虑以下 HLSL 源代码:
RWByteAddressBuffer Output : register(u0);
struct InputRecord{ uint index;};
[Shader("node")][NodeLaunch("broadcasting")][NodeDispatchGrid(1, 1, 1)][NumThreads(1, 1, 1)]void FirstNode([MaxRecords(1)] NodeOutput<InputRecord> SecondNode){ ThreadNodeOutputRecords<InputRecord> record = SecondNode.GetThreadNodeOutputRecords(1); record.Get().index = 0; record.OutputComplete();}
[Shader("node")][NodeLaunch("broadcasting")][NodeDispatchGrid(1, 1, 1)][NumThreads(1, 1, 1)]void SecondNode(DispatchNodeInputRecord<InputRecord> inputData){ Output.Store(inputData.Get().index * 4, 1);}这里有很多内容需要解读!
首先,请注意在此示例中 FirstNode 不接受输入记录。如果您一直在关注之前的 构建模块,您会希望回过头来移除我们在 DispatchWorkGraphAndReadResults() 中添加的指定 InputRecord 值的逻辑。
现在让我们检查 FirstNode 的 NodeOutput<> 参数。此参数的名称必须与在此工作图其他地方声明的目标节点的函数名称完全匹配(在本例中为 SecondNode)。另外请注意,目标节点必须指定一个 NodeInputRecord<>,其模板参数与此 NodeOutput<> 参数相同。
接下来,我们在 FirstNode 的主体内使用两个新函数:GetThreadNodeOutputRecords() 和 OutputComplete()。我将 GetThreadNodeOutputRecords() 理解为分配一个内存块来填充数据,而 OutputComplete() 将该块排队到一个输入记录集合中,供目标节点处理。您还会注意到 GetThreadNodeOutputRecords() 接受第二个参数,在此示例中该参数硬编码为 1。此参数仅在比此处提出的更复杂的用例中有用:请参阅 递归 部分以获取更详细的探讨!
另外请注意,NodeOutput<> 参数是用 [MaxRecords(n)] 属性装饰的。此常量表示单个线程组将调用 OutputComplete() 的最大输出记录数。在此示例中,此属性正确设置为 1,但它会根据每个着色器的内容和意图进行更改。在此示例中,如果您将 FirstNode 声明中的 [NumThreads(1, 1, 1)] 更改为 [NumThreads(2, 1, 1)],您还必须指定 [MaxRecords(2)]。
最后,请注意此工作图中有多个节点。回想一下,在填充 D3D12_DISPATCH_GRAPH_DESC 时,我们硬编码了 NodeCPUInput.EntrypointIndex = 0。在具有多个节点的图中,此假设可能不再足够,应重新评估。Microsoft DirectX® 12 工作图规范中的此块很好地涵盖了我们的用例:
…当指定(选择加入)D3D12_WORK_GRAPH_FLAG_INCLUDE_ALL_AVAILABLE_NODES 标志时,运行时会简单地包含所有从 DXIL 库导出的节点,将它们连接起来,并且图中未被其他节点寻址的节点被假定为图的入口点。
我们在调用 WorkGraphDesc->IncludeAllAvailableNodes() 时,在创建状态对象时包含了 D3D12_WORK_GRAPH_FLAG_INCLUDE_ALL_AVAILABLE_NODES 标志。在此示例中,SecondNode 被 FirstNode 显式寻址。在这些条件下,根据 DirectX12 工作图规范,FirstNode 仍然是唯一有效的入口点,我们假设它必须是 EntrypointIndex == 0 的假设仍然有效。有关解决此假设的更明确的想法,请参阅 按名称而非按索引引用工作图入口点 部分!
到目前为止,在所有示例中,所有工作图节点都使用了相同的 [NodeLaunch()] 属性值——"broadcasting"。此属性还有另外两个值:"thread" 和 "coalescing",这三种选项值得更详细地探讨。这些模式之间的区别在于一个线程组将接收多少输入记录,以及,更重要的是…有多少线程将处理每个输入记录。
在 "broadcasting" 节点中,整个调度网格接收一个输入记录(因此,已调度线程组中的所有线程都处理相同的输入记录)。尽管我们一直在查看使用 "broadcasting" 节点的示例,但所有这些示例也仅限于 [NumThreads(1, 1, 1)]…因此它们并不是您可能想要使用 "broadcasting" 节点的真正好例子。您可能更喜欢 "broadcasting" 节点的一个常见用例是,如果输入记录本身对需要执行的工作没有影响。例如:
一个 "thread" 节点实际上是“broadcasting”节点的反面。在 "thread" 节点中,线程组为执行的每个线程接收一个输入记录(每个线程处理的输入记录与其他线程不同)。您可能更喜欢 "thread" 节点的一个常见用例是,如果您有很多相似的数据单元需要以相同的方式处理…并且单个数据单元的处理也可以通过单线程执行路径有效地完成。
"coalescing" 节点介于这两种相反情况之间。一个 "coalescing" 节点为每个线程组接收可变的用户指定数量的输入记录(并使用每个算法数量的线程来处理每个输入记录)。这种灵活性带来了一些开销——"coalescing" 节点比 "broadcasting" 和 "thread" 启动的节点使用起来稍微复杂一些。请考虑以下 HLSL 源代码:
RWByteAddressBuffer Output : register(u0);
struct InputRecord{ uint index;};
[Shader("node")][NodeLaunch("broadcasting")][NodeDispatchGrid(1, 1, 1)][NumThreads(5, 1, 1)]void FirstNode( uint GroupIndex : SV_GroupIndex, [MaxRecords(5)] NodeOutput<InputRecord> SecondNode){ ThreadNodeOutputRecords<InputRecord> record = SecondNode.GetThreadNodeOutputRecords(1); record.Get().index = GroupIndex + 1; record.OutputComplete();}
[Shader("node")][NodeLaunch("coalescing")][NumThreads(32, 1, 1)]void SecondNode( uint GroupIndex : SV_GroupIndex, [MaxRecords(8)] GroupNodeInputRecords<InputRecord> inputData){ uint inputDataIndex = GroupIndex / 4; if (inputDataIndex < inputData.Count()) { Output.Store(GroupIndex * 4, inputData[inputDataIndex].index); }}"coalescing" 节点不接收 Dispatch|ThreadNodeInputRecords 参数,而是接收 GroupNodeInputRecords。它应始终用 [MaxRecords(N)] 注释,因为默认值为 1,在这种情况下,您可能一开始就不应该使用 "coalescing" 节点。在上面的示例中,我们将输入设置为 8,允许最多排队 8 个项目。
请注意,这些值是相互关联的:8 * 4 = 32。
GroupNodeInputRecords<>:每个线程组最多接收 **8** 个输入记录。inputDataIndex:每组 **4** 个线程处理相同的输入记录。[NumThreads()]:每个线程组包含 **32** 个线程。定义这些类型的关系使得通常将传统的计算语义(在本例中为 SV_GroupIndex)与“Coalescing”节点结合使用。
另外,请注意 Count() 函数的使用。GroupNodeInputRecords 的 [MaxRecords()] 注释是一个最大值,而不是保证。不能假定每次启动 "coalescing" 线程组时它都包含完整的输入记录负载,您需要确保所需数据存在才能访问它!
在工作图中,递归是指调用 OutputComplete() 在一个目标节点与调用 OutputComplete() 的节点相同的输出记录上,从而指示图在将来启动另一个线程,该线程将执行当前在可能不同的输入上执行的相同逻辑。基于 GPU 的递归可以成为实现循环算法或遍历某些数据结构的强大工具。工作图递归也可以用于更直接地实现 ExecuteIndirect 被(最坏情况)反复从 CPU 调用的现有模式。
使用工作图递归时有几个限制需要注意。请考虑以下 HLSL 源代码:
RWByteAddressBuffer Output : register(u0);
struct InputRecord{ uint depth;};
[Shader("node")][NodeLaunch("broadcasting")][NodeDispatchGrid(1, 1, 1)][NodeMaxRecursionDepth(5)][NumThreads(1, 1, 1)]void RecursiveNode( DispatchNodeInputRecord<InputRecord> inputData, [MaxRecords(1)] NodeOutput<InputRecord> RecursiveNode){ Output.Store(inputData.Get().depth * 4, GetRemainingRecursionLevels());
bool shouldRecurse = (GetRemainingRecursionLevels() > 0); GroupNodeOutputRecords<InputRecord> record = RecursiveNode.GetGroupNodeOutputRecords(shouldRecurse ? 1 : 0); if (shouldRecurse) { record.Get().depth = inputData.Get().depth + 1; record.OutputComplete(); }}首先,注意此节点附加的 [NodeMaxRecursionDepth()] 属性。所有递归节点都必须指定此属性,因为 API 定义了工作图节点执行的深度限制;在 Microsoft DirectX 12 工作图规范中,此限制为 32。
最长的节点链不能超过 32。递归地指向自己的节点根据其声明的最大递归级别计入此限制。
此限制在着色器编译期间(如果可能)强制执行,并在状态对象生成期间结合更多上下文再次执行。可以通过 DirectX 12 调试层轻松检索在此阶段识别的错误信息。
接下来,注意 GetRemainingRecursionLevels() 内置函数的用法。虽然 DirectX 12 运行时可以检测并阻止您声明超出深度上限的意图……但它无法阻止执行代码忽略您声明的意图并递归超出深度上限。此内置函数可用作防止超出递归预算的保护措施,在(可能)最佳情况下将导致 GPU 挂起……而在最坏情况下将导致非常晦涩的错误。
另请注意,此递归的循环结构非常简单——它只是一个指向自身的节点。这种简单性是当前允许的唯一用法!根据 Microsoft DirectX 12 工作图规范:
为了限制系统复杂性,递归是不允许的,但节点指向自身的递归除外。图中不能有其他循环。
最后,仔细查看 GetGroupNodeOutputRecord() 的调用。之前,我们将第二个参数硬编码为 1……但在本例中,我们传递了一个变量。概念上,您可以这样理解此模式:
bool shouldRecurse = (GetRemainingRecursionLevels() > 0);
if (shouldRecurse){ GroupNodeOutputRecords<InputRecord> record = RecursiveNode.GetGroupNodeOutputRecords(shouldRecurse ? 1 : 0); record.Get().depth = inputData.Get().depth + 1; record.OutputComplete();}虽然上面的代码可能更容易理解,但它也是无效的!根据 Microsoft DirectX 12 规范:
对此方法 [GetGroupNodeOutputRecord()] 的调用必须是线程组统一的,不能在任何可能在线程组内变化的流程控制中(变化包括线程退出),否则行为是未定义的。
此限制适用于 Get{Group|Thread}NodeOutputRecord 的所有变体,包括线程、组和数组排列。决定是否需要为此线程分配输出记录,然后从线程组不变的作用域将该决定作为第二个变量传递给 Get{Group|Thread}NodeOutputRecord。如果您决定不需要从输出记录数组中分配任何成员,请传递 0 而不是 1。
最后,请确保您只调用 OutputComplete() 在已实际分配的记录上!在 if 块内调用 OutputComplete() 不仅是合法的,事实上您几乎总是应该这样做。在未实际分配的输出记录上调用 OutputComplete() 会导致未定义的行为,包括 GPU 挂起。