工作图 API – 计算光栅化器学习示例

最初发布于:
最后更新于:
Niels Froehling's avatar
Niels Froehling

2024 年 3 月更新,针对 Microsoft® Work Graphs 1.0 的发布:使用此 最新 AMD 驱动程序

在本文中,我们将练习使用 Microsoft 早期发布的 Workgraph API。如果您不熟悉 Workgraph 的基本语法,GPUOpen 上有一个很棒的介绍,我建议您阅读 GPU 工作图入门

Workgraph 非常适合表达管道。当您考虑在 GPU 上实现一个算法时,您可能会经常将其视为一个整体程序,尽可能多地在 GPU 工作单元上运行。毕竟,使用当前的语言和 API 将算法分解为多个阶段的收益很小。

  • 您必须顺序处理每个阶段:工作单元的启动和完成期间的占用率很低。
  • 您必须将阶段之间的所有信息写入内存:这会吞噬您的共享内存带宽,因为缓存不太可能容纳完整的数据集。
  • 您必须在 CPU 上保守地分配最坏情况的内存占用,因为 GPU 上没有分配器:这已经可能使该想法不切实际。
  • 或者,您必须与 GPU 同步并读回信息:整个芯片的占用率会变得非常差。

无论通过对算法进行分阶段来创造任何工作量减少和整形的机会,都需要比由此带来的劣势更有价值。

Workgraphs API 不仅提供了表达管道和图的语法,它还解决了上述问题。

  • 图中的节点可以同时运行:缓存会变得有效。
  • 节点之间的通信内存是即时分配的:峰值内存消耗大大降低。

在本系列的这一部分,我们将选择一个小型示例算法,展示将其分解为阶段所带来的工作量减少和整形机会,以及 Workgraph 如何最大化其中固有的性能。

光栅化器

我们选择用于演示的小型示例算法是扫描线光栅化器。为了保持范围简单,我们选择了一种算法变体,它可以很好地处理近平面交线的边缘情况,并且不需要三角形裁剪器。此外,此示例并非旨在成为工业级的复制粘贴模板,它旨在教授 Workgraph,而许多与 Workgraph 无关的内容(如边缘平局和一些精度不准确或层次 Z 测试)已被忽略:使用 2D 齐次坐标进行三角形扫描转换

该算法可以简化为以下步骤:

  1. 顶点获取和变换
  2. 计算齐次空间方程
  3. 三角形形成和边界框计算
  4. 边界框扫描循环(按 x 和 y)
    1. 三角形内检测
    2. 属性插值
    3. 属性写出

这是一个小型算法(约 100 行代码),可以很好地解决三角形光栅化问题。我们将在后续中简单地将此算法称为光栅化器,当我们说到边界框时,指的是屏幕空间 AABB。

接下来,我们将设计算法,然后深入研究实现细节,最后将看到一些结果。在您自己的项目中,实验和考虑之间会有相当多的往返。但新的 Workgraphs 范例的一个优点是,这样做感觉非常舒适。一旦应用程序和着色器程序之间有了稳定的接口,您就可以主要留在着色器代码中并尝试不同的方法。

开发

让我们一步一步地将光栅化器算法转换为一种适合 GPU 运行并利用 Workgraph 功能的实现。当然,我们将把算法分解成更小的部分,利用在算法阶段之间自由路由数据,并剔除冗余数据。

  1. GPU 目前基本上是一个 SIMD 处理器。SIMD 向量的宽度因实现而异,通常在 16 到 64 个通道之间。

    光栅化器可以轻松矢量化。每个通道负责处理不同的三角形。扫描循环需要迭代,只要参与的三角形使用其参数。例如,如果有两个三角形,其边界框分别为 48x6 和 8x92,那么循环需要迭代 48x92 的范围,以便同时覆盖两者。

    这相当低效,因为几乎所有的面积并集都是空的,对任何一个三角形都没有用。最好将共享循环的维度从 2D 减少到 1D。两个示例三角形的循环参数分别为 288 和 736,这比之前产生的 4140 次迭代要好得多。

    float BBStrideX = (BBMax.x - BBMin.x + 1);
    float BBStrideY = (BBMax.y - BBMin.y + 1);
    float BBCoverArea = BBStrideY * BBStrideX - 1.0f;
    float y = BBMin.y;
    float x = BBMin.x;
    while (BBCoverArea >= 0.0f)
    {
    x += 1.0f;
    y += x > BBMax.x;
    if (x > BBMax.x)
    x = BBMin.x;
    BBCoverArea -= 1.0f;
  2. 尽管第一步已经是一个很好的改进,但如果我们能进一步改进它那就更好了。我们能否让三角形的面积彼此相似?这将使 SIMD 向量上的共享循环的参与者更加连贯,并且我们不会遇到处理小三角形与大三角形并存的情况。为此,我们将光栅化器分为两个阶段:

    1. 循环之前的所有内容
    2. 循环本身

    在这两个阶段之间,我们现在可以查看所有三角形,按面积对它们进行分组,然后启动具有相似大小三角形的循环。通常,这会在常规的 Compute API 中导致显著的开销(编程和执行),但使用 Workgraphs API,这相当直接,因为我们可以将第一阶段的输出分流到第二阶段的集合中。

    仍然存在一个棘手的问题,即三角形的面积可以介于 1 到整个屏幕(对于 4k,约为 800 万)之间。我们不想指定那么多扫描循环的变体。此外,对于真实场景,三角形大小的频率下降得相当快,您很难找到超过几个三角形占据大面积。相反,我们可以将三角形分箱到一组更易于管理的面积属性中。为了通用性,我们不应基于特定的三角形频率,而是选择预期的分布。对数是一个不错的选择,而底为 2 的计算非常快(也许您会发现与信息熵的联系)。

    // Calculate rasterization area and bin
    float2 BBSize = max(BBMax - BBMin + float2(1.0f1.0f), float2(0.0f0.0f));
    float  BBArea = BBSize.x * BBSize.y;
    int    BBBin  = int(ceil(log2(BBArea)));

  3. 为了支持 4k,我们将不得不提供 23 个光栅化循环:22.93 = log2(~8mil.),尽管即使应用了对数,最后几个 bin 中也会有非常少的三角形。此外,为了通用性,我们不会对三角形的大小设置限制。还有一个额外的担忧:在 GPU 这样的并行处理器上运行 800 万次迭代的着色器程序效率极低。我们可以改为将大面积分解为小面积。这听起来很熟悉,硬件光栅化器也是这样做的,它将屏幕空间细分为固定大小的瓦片,如果处理一个大三角形,它会将相同的三角形分发到许多瓦片。它允许硬件以适度的成本并行化三角形光栅化。这个想法为我们带来了三个好处:

    1. 无限的三角形大小
    2. 更少的循环变体 → bin → 节点
    3. 更小的循环迭代次数 → 更高的并行度 → 更好的占用率

    当我们在寻找优化机会时,还会出现一个间接的好处,那就是我们不需要光栅化空的边界框。一旦我们将原始三角形的边界框切分成更小的边界框,其中一些较小的边界框现在可能完全位于原始三角形的区域之外。边界框是对三角形的相当差的近似:基本上,边界框面积的一半是空白空间。如果我们的最大可分箱面积相对于输入面积很小,那么我们可以剔除大量不执行任何操作的迭代,这些迭代只是未能符合三角形的内部。

    1. 确定 x 和 y 的边界框细分因子,相对于面积限制
    2. 循环遍历边界框
      1. 计算子边界框
      2. 测试子边界框的角是否在三角形内部
      3. 对幸存的子边界框进行分箱

    这同样可以通过将细分因子直接传递给 Dispatch 来并行化,就像硬件所做的那样。

  4. 现在我们来看第一个 Workgraphs API 特定的优化。您可以使用具有动态 Dispatch 参数的 Broadcasting 节点进行启动。

    struct SplitRecord
    {
    uint3 DispatchGrid : SV_DispatchGrid;

    [Shader("node")]
    [NodeLaunch("broadcasting")]
    [NodeMaxDispatchGrid(2401351)] // Enough of a limit to allow practically unlimited sized triangles
    [NumThreads(SPLITR_NUM_THREADS, 11)]
    void SplitDynamic(

    或者您可以使用固定的 Dispatch 参数。

    [Shader("node")]
    [NodeLaunch("broadcasting")]
    [NodeDispatchGrid(111)] // Fixed size of exactly 1 work group
    [NumThreads(SPLITR_NUM_THREADS, 11)]
    void SplitFixed(

    固定 Dispatch 参数变体比动态 Dispatch 变体更容易由调度器进行优化。考虑一下,我们启动了 2x Dispatch X,1,1X,1,1,并且我们没有使用 YZ,如下面的“uint WorkIndex : SV_DispatchThreadID”。

    对于常规的 Compute Dispatch 和 Workgraphs 的动态 Dispatch,这些是未知的和可变的 X,调度器和分配器需要为这些未知数做好准备。但是 Workgraphs 的固定 Dispatch 知道它的 X 始终相同,并且知道您没有使用 YZ。因此,调度器可以将参数合并为一个 X,2,1 并将其作为一个 dispatch 启动。

    我们可以在 Workgraphs 中实现这一点,作为一个分支:如果边界框需要分片到少于 32 个片段,我们使用固定 Dispatch,否则我们使用动态 Dispatch。对此有效的关键再次是三角形大小分布,小三角形应该更频繁,大三角形应该很少。

Workgraph 方案

这结束了我们利用 work graphs API 范例的光栅化器设计。部分思维过程与流水线化算法或多线程大型顺序过程相似。利用使 SIMD 向量相关的数据更具连贯性的可能性(如果需要)以获得更好的性能。或者考虑在算法分支处将其拆分。这可以消除活动工作集中的死通道,或恢复条件代码片段的利用率。与所有其他细粒度多线程实践一样,尝试将节点中的计算成本保持得(如果不完全相同)尽可能短。它能带来更好的占用率,并使您的分配能够被有效重用。

这当然不是改进的详尽列表。您还可以尝试其他方法:

  • 顶点共享
  • 全填充 vs. 部分填充的边界框
  • 小型 vs. 大型光栅化循环专门化 – 一个像素 bin 根本不需要循环
  • 将细分限制在至少一个波形大小以最大化并行度
  • 等等

下面是我们将随后实现的最终显式算法图。

数据结构

我们用于为算法提供场景数据的基本数据结构如下:

struct Workload
{
uint offsetIndex;  // local cluster location in the global shared index buffer
uint offsetVertex; // local cluster location in the global shared vertex buffer
// (indices are cluster local)
uint startIndex;   // start of the triangle-range in the index buffer
uint stopIndex;    // end of the triangle range in the index buffer, inclusive
float4 vCenter;
float4 vRadius;
matrix mWorldCurrent;
matrix mWorldPrevious;
};

Workload 项描述了要光栅化的单个对象。索引和顶点都包含在一个连续可访问的缓冲区中,因为这允许我们使用 DirectX 的传统绑定范例,只绑定两个缓冲区描述符。您也可以选择无绑定范例,并使用描述符索引代替全局缓冲区索引。

此外,我们还传递对象的 P​​osition 和 Extent,以及其当前的世界矩阵。对于静态对象,我们只需要传输此缓冲区一次,因为信息不会随时间改变,但对于本项目,数据量足够小,可以每帧传输,并且我们不因需要区分动态对象而使事情变得更复杂。

我们在 CPU 上进行视锥体剔除,并将活动的场景元素作为 Workload 项的数组写入,然后启动工作图。

Workgraph 实现

HLSL

节点

从上图来看,我们需要从算法的角度实现三个不同的阶段:

  • 三角形获取和变换 + 边界框计算 + 分箱
  • 边界框细分和剔除 + 分箱
  • 边界框光栅化

对于细分,我们有两种启动签名(固定 vs. 动态),但节点主体中的算法是相同的。对于光栅化也是如此,尽管我们有 *k* 种不同的 bin,它只是将数据分组以使其更连贯,并不要求我们有专门的实现。

TriangleFetchAndTransform

这是图的根节点。它也是程序的入口点。它的特点是常规的 Compute Dispatch,我们启动的线程数量与三角形数量相同,但共享对象的 ID 和参数,因此我们将其注释为 Broadcasting 类型。

[Shader("node")]
[NodeLaunch("Broadcasting")]
[NodeMaxDispatchGrid(6553511)]
[NodeIsProgramEntry]
[NumThreads(TRANSF_NUM_THREADS, 11)]
void TriangleFetchAndTransform(
uint WorkgroupIndex : SV_GroupID,
uint SIMDLaneIndex : SV_GroupIndex,

节点由 CPU 馈送,类似于间接参数缓冲区,我们稍后将在 C++ 部分介绍。

Split

这是放大边界框的节点,我们启动一个与计算出的子细分一样大的 dispatch 网格,但共享包含的原点边界框和三角形参数,它也是 Broadcasting 类型。

[Shader("node")]
[NodeLaunch("broadcasting")]
#if (EXPANSION == 1) // fixed expansion
[NodeDispatchGrid(111)] // has the size of one wave, consumes no
dispatch parameters
[NumThreads(SPLITR_NUM_THREADS, 11)]
void SplitFixed(
#elif (EXPANSION == 2) // dynamic expansion
[NodeMaxDispatchGrid(2401351)] // can be very large, consumes
dispatch parameters
[NumThreads(SPLITR_NUM_THREADS, 11)]
void SplitDynamic(
#else
[NodeMaxDispatchGrid(2401351)]
[NumThreads(SPLITR_NUM_THREADS, 11)]
void Split(
#endif
uint  WorkSIMDIndex : SV_DispatchThreadID, // use only one dimension to allow optimizations of the dispatch

为了生成不同的签名(和函数名)排列,我们使用预处理器条件,并将节点从同一代码编译多次。因为我们对固定节点和动态节点使用不同的函数签名,根据当前规范修订版,它们彼此不兼容/不可互换。因此,我们无法使用分箱/索引机制来定位它们,而是必须显式地将数据中继给另一个。

Rasterize

图的叶节点是光栅化器。每个线程处理自己的边界框和三角形,它们之间没有有用的信息可以共享。此节点为 Thread 类型。

[Shader("node")]
[NodeLaunch("thread")]
[NodeID("Rasterize", TRIANGLE_BIN)] // indicate position in a node-array, passed in as a preprocessor symbol while compiling the node
void Rasterize(

一个 Thread 启动节点不受工作集大小的显式参数控制,而是其实现的工作方式类似于生产者-消费者队列。一旦积累了足够的工作来启动一个 SIMD 波形,它就会以与通道数相同的单个参数启动。这非常高效,因为可以内部选择启动线程的最佳数量,而我们不需要完全了解条件。例如,此值可能因架构甚至 SKU 而异。或者有时调度器最好通过防止停顿、恢复分配或在 SIMD 单元上预加载着色器程序来发出工作。

由于光栅化器在代码方面完全相同,我们可以使用 Workgraph 强大的节点数组概念:而不是通过显式条件结构(if/else、switch 等)重定向工作,这个概念类似于跳转表。

数据传递

剩下要做的就是定义节点之间的连接。这以自上而下的方式显式指定:输出显式指向它们的目标节点。为确保这些连接有效,输入和输出都指定了它们携带的数据结构,以便运行时可以验证连接是否有效。结果是一个有向无环图。

输入和输出的声明是函数参数声明的一部分,包括它们的本地注释。这可能使 Workgraph 函数签名非常庞大。在下文中,我将仅显示与我们正在解释的数据记录相关的函数签名部分。在代码中,您会在一个大块中看到它们。

Application → TriangleFetchAndTransform

首先,我们定义如何将应用程序的数据馈送到图的根节点。因为根节点是 Broadcasting 类型,我们在数据声明中使用必需的 SV_DispatchGrid 语义。指向全局工作负载缓冲区的 workload-offset 类似于根签名常量(它在所有调用中都相同)。

struct DrawRecord
{
// index into the global scene buffer containing all Workload descriptors
uint  workloadOffset;
uint3 DispatchGrid : SV_DispatchGrid;
};

现在我们将此结构声明为用于输入到我们根节点函数签名的类型。

void TriangleFetchAndTransform(
// Input record that contains the dispatch grid size.
// Set up by the application.
DispatchNodeInputRecord<DrawRecord> launchRecord,

当我们组合 dispatch 全局参数和 dispatch 局部参数时,我们可以确切地知道每个线程应该处理哪个三角形。

TriangleFetchAndTransform → SplitFixed/SplitDynamic

接下来,我们定义如何将数据从根节点传递到边界框细分节点。因为我们为此使用了两种不同的节点类型(一个固定 Dispatch 和一个动态 Dispatch,具有不兼容的函数签名),我们也想声明两种不同类型的数据结构。动态 Dispatch 必须在数据声明中使用必需的 SV_DispatchGrid 语义。固定 Dispatch 也可以使用一个,因为编译器会静默清除未使用的语义参数,但我们保留两个版本以保持清晰和无歧义的编译时验证。

struct SplitDynamicRecord
{
uint3 DispatchGrid : SV_DispatchGrid;
// Triangle equation, bbox and interpolateable values
struct TriangleStateStorage tri;
uint2 DispatchDims; // 2D grid range processed by this 1D dispatch
uint2 DispatchFrag; // 2D size of the subdividing bounding boxes
};
struct SplitFixedRecord
{
// Triangle equation, bbox and interpolateable values
struct TriangleStateStorage tri;
uint2 DispatchDims; // 2D grid range processed by this 1D dispatch
uint2 DispatchFrag; // 2D size of the subdividing bounding boxes
};

然后,我们将这两个记录类型添加为根节点函数签名的独立输出。对于每个输出,我们必须注释更多内容:

  • 我们的工作组最多可能产生多少输出数据?
  • 接收输出数据的节点名称是什么?

前者允许分配器根据情况切换分配策略,后者明确地确定数据去向。

void TriangleFetchAndTransform(
[MaxRecords(TRANSF_NUM_THREADS)] // N threads that each output 1 bbox-split
[NodeID("SplitFixed")]
NodeOutput<SplitFixedRecord> splitFixedOutput,
[MaxRecords(TRANSF_NUM_THREADS)] // N threads that each output 1 bbox-split
[NodeID("SplitDynamic")]
NodeOutput<SplitDynamicRecord> splitDynamicOutput,

然后我们将此结构声明为用于输入到我们细分节点函数签名的类型。

void SplitFixed/Dynamic(
// Input record that may contain the dispatch grid size.
// Set up by TriangleFetchAndTransform.
#if (EXPANSION == 1) // fixed expansion
DispatchNodeInputRecord<SplitFixedRecord> splitRecord,
#else // dynamic expansion
DispatchNodeInputRecord<SplitDynamicRecord> splitRecord,
#endif

我们不更改函数体,因为它执行相同的操作,并且参数保持不变。而是我们通过函数签名来专门化其调用方式。

TriangleFetchAndTransform 和 SplitFixed/SplitDynamic → Rasterize

最后,我们定义如何将图表中所有较高节点的数据传递到光栅化器。现在剩下的唯一必要数据,用于光栅化循环,实际上只是三角形方程、要扫描的边界框以及我们想要插补的值。

struct RasterizeRecord
{
// Triangle equation, bbox and interpolateable values
struct TriangleStateStorage tri;
};

尽管如此,正如我们在文章开头所计划的,我们希望将具有相似性能特征的数据收集在一起。实现这一目标的本机 Workgraph 构造是输出数组。到目前为止,我们只定位了单个输出。输出数组可以理解为绑定的输出束。我们使用索引来选择输出到哪个,而不是使用流程控制。当只有几个输出时,这可能没有太大区别,但一旦涉及几百个输出,使用分支就会非常低效。我们在输出的注释中指定输出数组的大小,以及输出限制和输出节点名称。与多个输出一样,不要求只选择一个选项并互斥地输入数据。如果算法要求您向多个输出(或输出数组中的多个槽)输入数据,这完全没问题。请记住调整您的峰值输出大小。

void TriangleFetchAndTransform(
[MaxRecords(TRANSF_NUM_THREADS)] // N threads that each output 1 triangle
[NodeID("Rasterize")]
[NodeArraySize(TRIANGLE_BINS)]  // TRIANGLE_BINS defined by application
// during compilation
NodeOutputArray<RasterizeRecord> triangleOutput

void SplitFixed/Dynamic(
[MaxRecords(SPLITR_NUM_THREADS)] // N threads that each output 1 triangle
[NodeID("Rasterize")]
[NodeArraySize(TRIANGLE_BINS)]  // TRIANGLE_BINS defined by application
// during compilation
NodeOutputArray<RasterizeRecord> triangleOutput

尽管我们可能通过不同的路径到达光栅化节点,但结构是相同的,声明也非常直接。

void Rasterize(
ThreadNodeInputRecord<RasterizeRecord> triangleRecord

就像在常规的 Compute 算法中一样,我们将光栅化输出写入 UAV,这在 Workgraphs 中不需要特殊声明。

数据处理

输入和输出已声明并准备就绪,我们可以实现 i/o。这相当直接,我将不一一介绍。

如果您有一个单一输入(如 BroadcastingThread 的情况),我们可以对声明的输入调用一个简单的 getter。

uint workloadOffset = launchRecord.Get().workloadOffset;

就是这样。对于 Coalescing 启动,您只需将要检索的元素的索引传递给 getter。

输出稍微复杂一些。根据规范,您必须以一致的方式调用输出记录分配器(所有通道都离开,或都不离开)。为了允许按通道单独分配,API 接受一个布尔值来指示您是否真的想要记录,或者它是否是虚拟分配。

让我们看一下根节点的输出代码。

const uint triangleBin  = ts.triangleBBBin; // calculated bin, if not too large
const bool allocateRasterRecordForThisThread  = ts.triangleValid; // true if not too large
const bool allocateSplitDynamicRecordForThisThread = !allocateRasterRecordForThisThread & (DispatchGrid.x != 1); // too large, and many work groups necessary for subdivision
const bool allocateSplitFixedRecordForThisThread  = !allocateRasterRecordForThisThread & (DispatchGrid.x == 1); // too large, but only one work group necessary for subdivision
// call allocators outside of branch scopes, the boolean indicates if we want a real record or not
ThreadNodeOutputRecords<RasterizeRecord> rasterRecord =
triangleOutput[triangleBin]
.GetThreadNodeOutputRecords(allocateRasterRecordForThisThread);
ThreadNodeOutputRecords<SplitDynamicRecord> splitterDynamicRecord =
splitDynamicOutput
.GetThreadNodeOutputRecords(allocateSplitDynamicRecordForThisThread);
ThreadNodeOutputRecords<SplitFixedRecord> splitterFixedRecord =
splitFixedOutput
.GetThreadNodeOutputRecords(allocateSplitFixedRecordForThisThread);
// fill the acquired output records, here we branch according to the conditions
if (allocateRasterRecordForThisThread)
{
rasterRecord.Get().tri = StoreTriangleState(ts);
}
else if (allocateSplitFixedRecordForThisThread)
{
splitterFixedRecord.Get().DispatchDims = DispatchDims;
splitterFixedRecord.Get().DispatchFrag = DispatchFrag;
splitterFixedRecord.Get().tri = StoreTriangleState(ts);
}
else if (allocateSplitDynamicRecordForThisThread)
{
splitterDynamicRecord.Get().DispatchGrid = DispatchGrid;
splitterDynamicRecord.Get().DispatchDims = DispatchDims;
splitterDynamicRecord.Get().DispatchFrag = DispatchFrag;
splitterDynamicRecord.Get().tri = StoreTriangleState(ts);
}
// call completion outside of branch scopes, this allows the scheduler to start processing them
rasterRecord.OutputComplete();
splitterDynamicRecord.OutputComplete();
splitterFixedRecord.OutputComplete();

我们有三种可能的输出场景:

  1. 直接输出到光栅化器,使用 bin 作为输出数组的索引。
  2. 输出到没有系统值的固定 Dispatch 分割器。
  3. 输出到具有系统值的动态 Dispatch 分割器。

由于我们注意不围绕这些调用进行分支,它看起来有点冗长,但并没有太复杂的。您从输出分配器获得的记录与您通过函数接收的输入记录的类型相同。您需要以相同的方式调用 getter。如果您分配了多个条目(本项目中没有),则传递您要访问的子分配记录的索引,与在 Coalescing 启动中提供的记录集合相同。

至此,我们在着色器代码中实现了所有与 work graphs 相关的内容。在下一章中,我们将看看如何从 CPU 端启动工作图。

C++ 和 Direct3D 12

创建状态对象

在 D3D12 中创建 Workgraph 程序与创建常规的 Graphics 或 Compute pipeline State Object 略有不同。从概念上讲,它类似于为光线追踪创建 State Object(您也需要注册大量子程序)。

首先,我们将定义 Workgraph 的描述,并指定入口点/根节点。

D3D12_NODE_ID entryPointId = {
.Name = L"TriangleFetchAndTransform", // name of entry-point/root node
.ArrayIndex = 0,
};
D3D12_WORK_GRAPH_DESC workGraphDesc = {
.ProgramName = L"WorkGraphRasterization",
.Flags = D3D12_WORK_GRAPH_FLAG_INCLUDE_ALL_AVAILABLE_NODES,
.NumEntrypoints = 1,
.pEntrypoints = &entryPointId,
.NumExplicitlyDefinedNodes = 0,
.pExplicitlyDefinedNodes = nullptr
};

然后,我们将所有节点编译成单独的着色器 blob,调整预处理器符号,以便获得我们想要的精确排列。

CompileShaderFromFile(sourceWG, &defines,
"TriangleFetchAndTransform""-T lib_6_8" OPT, &shaderComputeFrontend);
CompileShaderFromFile(sourceWG, &defines,
"SplitDynamic""-T lib_6_8" OPT, &shaderComputeSplitter[0]);
CompileShaderFromFile(sourceWG, &defines,
"SplitFixed""-T lib_6_8" OPT, &shaderComputeSplitter[1]);
for (int triangleBin = 0; triangleBin < triangleBins; ++triangleBin)
CompileShaderFromFile(sourceWG, &defines,
"Rasterize""-T lib_6_8" OPT, &shaderComputeRasterize[triangleBin);

我们在此指定的函数名称必须与 HLSL 源代码中的名称匹配,以便编译器知道我们想要源代码的哪个部分。您可以在这里看到,对于分离器,我们可以使用明确的函数名称,因为我们在 HLSL 中更改了它们的名称。对于光栅化函数,我们将不这样做,因为对于大量节点来说,这样做是不切实际的。请记住,我们可能有数百个这样的节点!

相反,我们使用 API 的一个巧妙功能:在链接程序时更改函数名称,但稍后会详细介绍。

在编译完所有单独的着色器 blob 后,我们可以将它们注册为 Workgraph 程序的子对象。

std::vector<D3D12_STATE_SUBOBJECT> subObjects = {
{.Type = D3D12_STATE_SUBOBJECT_TYPE_GLOBAL_ROOT_SIGNATURE,
.pDesc = &globalRootSignature},
{.Type = D3D12_STATE_SUBOBJECT_TYPE_WORK_GRAPH,
 .pDesc = &workGraphDesc},
};
D3D12_DXIL_LIBRARY_DESC shaderComputeFrontendDXIL = {
.DXILLibrary = shaderComputeFrontend,
.NumExports = 0,
.pExports = nullptr
};
subObjects.emplace_back(D3D12_STATE_SUBOBJECT_TYPE_DXIL_LIBRARY, &shaderComputeFrontendDXIL); // move into persistent allocation
D3D12_DXIL_LIBRARY_DESC shaderComputeSplitter0DXIL = {
.DXILLibrary = shaderComputeSplitter[0],
.NumExports = 0,
.pExports = nullptr
};
subObjects.emplace_back(D3D12_STATE_SUBOBJECT_TYPE_DXIL_LIBRARY, &shaderComputeSplitter0DXIL); // move into persistent allocation
D3D12_DXIL_LIBRARY_DESC shaderComputeSplitter1DXIL = {
.DXILLibrary = shaderComputeSplitter[1],
.NumExports = 0,
.pExports = nullptr
};
subObjects.emplace_back(D3D12_STATE_SUBOBJECT_TYPE_DXIL_LIBRARY, &shaderComputeSplitter1DXIL); // move into persistent allocation

到目前为止一切顺利。现在我们要注册光栅化器着色器 blob,并且必须重命名它们。链接器将自动将函数名称与我们在 HLSL 中给出的输出数组声明进行匹配。

[NodeID("Rasterize")]
NodeOutputArray<RasterizeRecord> triangleOutput

匹配器遵循的模式是“<函数名>_<索引>”。我们将在下面指定,我们希望将“Rasterize”重命名为“Rasterize_0”,以此类推。

for (int triangleBin = 0; triangleBin < triangleBins; ++triangleBin)
{
auto entryPoint = L"Rasterize";
// Change "Rasterize" into "Rasterize_k"
refNames.emplace_back(std::format(L"{}_{}", entryPoint, triangleBin)); // move into persistent allocation
// Tell the linker to change the export name when linking
D3D12_EXPORT_DESC shaderComputeRasterizeExport = {
.Name = refNames.back().c_str(),
.ExportToRename = entryPoint,
.Flags = D3D12_EXPORT_FLAG_NONE
};
expNames.emplace_back(shaderComputeRasterizeExport); // move into persistent allocation
D3D12_DXIL_LIBRARY_DESC shaderComputeRasterizeDXIL = {
.DXILLibrary = shaderComputeRasterize[triangleBin],
.NumExports = 1,
.pExports = &expNames.back()
};
libNames.emplace_back(shaderComputeRasterizeDXIL); // move into persistent allocation
subObjects.emplace_back(D3D12_STATE_SUBOBJECT_TYPE_DXIL_LIBRARY, &libNames.back());
}

此外,还有一些代码可以防止悬空指针导致范围外的解分配。

现在我们已经收集并定义了创建 Workgraph 程序所需的所有必要信息。

const D3D12_STATE_OBJECT_DESC stateObjectDesc = {
.Type = D3D12_STATE_OBJECT_TYPE_EXECUTABLE,
.NumSubobjects = static_cast<UINT>(subObjects.size()),
.pSubobjects = subObjects.data()
};
ThrowIfFailed(m_pDevice->CreateStateObject(&stateObjectDesc, IID_PPV_ARGS(&m_pipelineWorkGraph)));

它的设置肯定比预定义的硬件流水线阶段(VS、PS、CS 等)要复杂,但它允许您灵活地将各种不同的 Workgraph 从通用构建块组装起来,而无需将这种多样性的规范泄露到您的 HLSL 代码中。

分配底层内存

正如我们一开始所说,Workgraphs 的一个有益功能是它允许您进行分配,以便将数据从一个节点传递到下一个节点。但实现从中提取分配的内存池不是隐藏的隐式池。获取 Workgraph State Object 后,我们必须弄清楚该内存池的总大小应该是多少,并且我们还必须自己分配缓冲区并将其提供给实现。

ComPtr<ID3D12WorkGraphProperties> workGraphProperties;
ThrowIfFailed(m_pipelineWorkGraph->QueryInterface(IID_PPV_ARGS(&workGraphProperties)));
D3D12_WORK_GRAPH_MEMORY_REQUIREMENTS workGraphMemoryRequirements;
workGraphProperties->GetWorkGraphMemoryRequirements(0&workGraphMemoryRequirements);
const auto workGraphBackingMemoryResourceDesc = CD3DX12_RESOURCE_DESC::Buffer(workGraphMemoryRequirements.MaxSizeInBytes);
const auto defaultHeapProps = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT);
ThrowIfFailed(m_pDevice->CreateCommittedResource(
&defaultHeapProps,
D3D12_HEAP_FLAG_NONE,
&workGraphBackingMemoryResourceDesc,
D3D12_RESOURCE_STATE_COMMON,
nullptr,
IID_PPV_ARGS(&m_memoryWorkGraph
)));
const D3D12_GPU_VIRTUAL_ADDRESS_RANGE backingMemoryAddrRange = {
.StartAddress = m_memoryWorkGraph->GetGPUVirtualAddress(),
.SizeInBytes = workGraphMemoryRequirements.MaxSizeInBytes,
};

底层内存的大小取决于您的 Workgraph 的结构:有多少输出?输出的类型是什么?等等。内存大小将足以让 Workgraph 实现始终能够取得进展。

运行程序

最后,我们准备好在 GPU 上启动 Workgraph。我们有:

  • 一个包含 DrawRecords 的缓冲区,其中包含我们根节点的输入。
  • 一个用于提取分配的底层内存。
  • 以及一个包含我们实现的 State Object。

同样,设置 Workgraphs 实现需要我们设置一些结构来告诉运行时我们确切想要发生什么。

ComPtr<ID3D12WorkGraphProperties> workGraphProperties;
ThrowIfFailed(m_pipelineWorkGraph->QueryInterface(IID_PPV_ARGS(&workGraphProperties)));
ComPtr<ID3D12StateObjectProperties1> workGraphProperties1;
ThrowIfFailed(m_pipelineWorkGraph->QueryInterface(IID_PPV_ARGS(&workGraphProperties1)));
D3D12_WORK_GRAPH_MEMORY_REQUIREMENTS workGraphMemoryRequirements;
workGraphProperties->GetWorkGraphMemoryRequirements(0&workGraphMemoryRequirements);
// Define the address and size of the backing memory
const D3D12_GPU_VIRTUAL_ADDRESS_RANGE backingMemoryAddrRange = {
.StartAddress = m_memoryWorkGraph->GetGPUVirtualAddress(),
.SizeInBytes = workGraphMemoryRequirements.MaxSizeInBytes,
};
// Backing memory only needs to be initialized once
const D3D12_SET_WORK_GRAPH_FLAGS setWorkGraphFlags =
m_memoryInitialized ? D3D12_SET_WORK_GRAPH_FLAG_NONE
 : D3D12_SET_WORK_GRAPH_FLAG_INITIALIZE;
m_memoryInitialized = true;
const D3D12_SET_WORK_GRAPH_DESC workGraphProgramDesc = {
.ProgramIdentifier = workGraphProperties1->GetProgramIdentifier(L"WorkGraphRasterization"),
.Flags = setWorkGraphFlags,
.BackingMemory = backingMemoryAddrRange
};
const D3D12_SET_PROGRAM_DESC programDesc = {
  .Type = D3D12_PROGRAM_TYPE_WORK_GRAPH,
.WorkGraph = workGraphProgramDesc
};
// Set the program and backing memory
pCommandList->SetProgram(&programDesc);
// Submit all of our DrawRecords in one batch-submission
const D3D12_NODE_CPU_INPUT nodeCPUInput {
.EntrypointIndex = 0,
.NumRecords = UINT(m_Arguments.size()),
.pRecords = (void*)m_Arguments.data(),
.RecordStrideInBytes = sizeof(DrawRecord),
};
const D3D12_DISPATCH_GRAPH_DESC dispatchGraphDesc = {
.Mode = D3D12_DISPATCH_MODE_NODE_CPU_INPUT,
.NodeCPUInput = nodeCPUInput,
};
pCommandList->DispatchGraph(&dispatchGraphDesc);

这将启动与场景中的对象数量相等的 Workgraph 实例。每个 Workgraph 实例都通过其根节点中每个对象中的顶点数量,并继续通过图直到到达叶节点,在那里我们将光栅化和插值的值写入屏幕大小的 UAV。

结果

文章到此结束,让我们来看看结果,以及一切是否如我们预期的那样工作。在 rasterizer repository 的实现中,我们提供了各种控件,以便您可以实时探索性能差异。

Sponza

该场景来自 glTFSample repository。这是 Crytek 的 Sponza 场景。它有一些非常大的三角形,也有很多小三角形。较暗的颜色表示较小的边界框。

时间以 10ns 滴答为单位。测试的 GPU 是 7900 XTX。测试的驱动程序是 23.20.11.01。为了更好地可视化,我们绘制了一个图表,其中log10(ratio)与单片(纯 GPU SIMD)实现进行比较。

10ns 滴答log10(比率)
分箱单片 ExIn多通道 ExIn动态 WoGr固定 WoGr
6959876462570052676002158076
7959876489893239649921842596
8959876469897630209561730860
9959876477672024668081661224
10959876450787621116401598632
11959876446753218968001575920
1295987643737041461440395980
1395987643476041229584368704
149598764301444863680248180
159598764320772731676310732

正如您所见,性能在很大程度上取决于分箱的数量(以及因此光栅化边界框的最大尺寸)。但是,最佳点似乎在 14 个分箱左右(大约是 214 = 16k 像素,其中包含 128x128)。Workgraph 实现具有中等大小的底层缓冲区,约 130MB。它比单片 ExecuteIndirect 实现快 38 倍以上。固定 Dispatch 变体比仅动态 Dispatch 变体快约 3 倍。

如果我们将它与复杂的、内存消耗较大且占用大量内存带宽的、多通道的 ExecuteIndirect 实现进行比较,我们仍然可以获得约 1.2 倍的优势。

建议

  • 如果您可以利用在输入之间共享数据:使用 Coalescing 节点,否则使用 Thread
  • 静态参数可以优化,动态参数不能:在合适的情况下使用固定扩展。
  • 减小 dispatch 的尺寸以帮助调度器。
  • 尽量从最少的输出开始,以帮助分配器(分配很昂贵)。
  • 测量更昂贵的附加输出是否可以通过更好的方法来补偿(请参阅固定与动态)。

可用性

本教程的示例代码可在 GitHub 上找到:https://github.com/GPUOpen-LibrariesAndSDKs/WorkGraphComputeRasterizer

为了让您能够尝试各种场景,它基于 GitHub 上已有的 glTFSample:https://github.com/GPUOpen-LibrariesAndSDKs/glTFSample

您可以在 GitHub 上找到一个很棒的 glTF 场景存储库:https://github.com/KhronosGroup/glTF-Sample-Models

本教程中涉及的源代码可以在这里找到:

  • DirectX 12 设置/管理代码:libs/Cauldron/src/DX12/GLTF/GltfRasterizerPass.cpp
  • DirectX 12 着色器:libs/Cauldron/src/DX12/shaders/Rasterizer*.hlsl

免责声明

第三方网站链接仅为方便用户提供,除非另有明确说明,AMD不对任何此类链接网站的内容负责,且不暗示任何认可。GD-98

Microsoft 是 Microsoft Corporation 在美国和/或其他国家/地区的注册商标。本出版物中使用的其他产品名称仅用于标识目的,并可能为其各自所有者的商标。

DirectX 是 Microsoft Corporation 在美国和/或其他国家/地区的注册商标。

Niels Froehling's avatar

Niels Froehling

Niels Fröhling 是 AMD 的首席技术员工。他目前专注于 GPU Workgraph。在加入 AMD 之前,他多年来一直在游戏行业工作,开发 AAA PC、主机和 VR 游戏。他一生对数据压缩算法、并行算法和加速结构充满兴趣。

相关新闻和技术文章

相关视频

© . This site is unofficial and not affiliated with AMD.