AMD FidelityFX™ Variable Shading
AMD FidelityFX Variable Shading 将可变速率着色引入您的游戏。

欢迎收听来自 Second Order LTD 的联合创始人 Sebastian Aaltonen 的客座演讲。Sebastian 曾是育碧®的高级渲染主管。Second Order 最近宣布了他们的第一款游戏 Claybook!这款游戏不仅看起来非常有趣,其渲染器也十分新颖,以非传统的方式使用 GPU 来实现其独特的外观。 快来看看 Claybook!
Sebastian 将要讨论他在开发 Claybook 时遇到的一个有趣问题:如何在优化使用大型线程组的计算着色器的 GPU 占用率和资源使用。
在使用计算着色器时,考虑线程组大小对性能的影响非常重要。有限的寄存器空间、内存延迟和 SIMD 占用率都会以不同的方式影响着色器性能。本文讨论了潜在的性能问题,以及正确应用后可以显著提高性能的技术和优化方法。本文将重点关注大型线程组的问题集,但这些技巧和窍门对于一般情况也很有帮助。
DirectX® 11 Shader Model 5 计算着色器规范 (2009) 要求每个线程组的最大允许内存大小为 32 KiB,最大工作组大小为 1024 个线程。寄存器数量没有指定最大值,编译器可以根据寄存器压力需要将寄存器溢出到内存。然而,由于内存延迟,溢出会带来显著的负面性能影响,在生产代码中应避免。
现代 AMD GPU 能够在单个计算单元 (CU) 上同时执行两个 1024 个线程的组。然而,为了最大化占用率,着色器必须最小化寄存器和 LDS 的使用,以便所有线程所需的资源都能装入 CU。
考虑 GCN 计算单元 (CU) 的架构

一个 GCN CU 包含四个 SIMD,每个 SIMD 拥有一个 64 KiB 的 32 位 VGPR(向量通用寄存器)寄存器文件,每个 CU 总共有 65,536 个 VGPR。每个 CU 还有一个 32 位 SGPR(标量通用寄存器)寄存器文件。直到 GCN3,每个 CU 包含 512 个 SGPR,而从 GCN3 开始,数量增加到 800 个。这意味着每个 CU 有 3200 个 SGPR,即 12.5 KiB。
CU 调度的最小工作单元称为 wave,每个 wave 包含 64 个线程。CU 中的四个 SIMD 中的每一个都可以调度最多 10 个并发 wave。CU 可以在等待内存操作完成时挂起一个 wave,然后执行另一个 wave。这有助于隐藏延迟并最大化 CU 的计算资源使用。
SIMD VGPR 文件的大小引入了一个重要限制:SIMD 的 VGPR 在活动 wave 的线程之间平均分配。如果一个着色器需要的 VGPR 超过可用数量,SIMD 将无法执行最佳数量的 wave。因此,占用率(GPU 在给定时间可以执行的并行工作量)将受到影响。
每个 GCN CU 拥有 64 KiB 的本地数据共享 (LDS)。LDS 用于存储计算着色器线程组的组共享数据。Direct3D 将单个线程组可以使用的组共享数据量限制在 32 KiB。因此,我们需要至少在每个 CU 上运行两个组才能充分利用 LDS。
本文的示例着色器是一个复杂的 GPGPU 物理求解器,线程组大小为 1024。该着色器使用了最大组大小和最大量的组共享内存。它受益于较大的组大小,因为它使用组共享内存作为多个传递之间的临时存储来求解物理约束。更大的线程组大小意味着可以在无需将临时结果写入全局内存的情况下处理更大的“岛屿”。
现在,让我们讨论为了高效运行 1024 个线程的组,我们必须满足的资源目标。
如果着色器超过这些限制,CU 上将没有足够的资源同时运行两个组。32 VGPR 的目标很难达到。我们将首先讨论如果您未达到此目标时面临的问题,然后是解决此问题的方法,最后是如何完全避免它。
考虑一个应用程序使用最大组大小 1024 个线程,但着色器需要 40 个 VGPR 的情况。在这种情况下,每个 CU 一次只能执行一个线程组。运行两个组,即 2048 个线程,将需要 81,920 个 VGPR,远远超过 CU 上可用的 65,536 个 VGPR。
1024 个线程将产生 16 个 64 线程的 wave,这些 wave 平均分布在 SIMD 之间,每个 SIMD 有 4 个 wave。我们之前了解到,最佳占用率和延迟隐藏需要 10 个 wave,所以 4 个 wave 只产生 40% 的占用率。这显著降低了 GPU 的延迟隐藏潜力,导致 SIMD 利用率降低。
假设您的 1024 线程组使用了最大的 32 KiB LDS。当只有一个组运行时,50% 的 LDS 未被利用,因为它被预留给第二个线程组,而由于寄存器压力,该线程组不存在。寄存器文件使用量为每个线程 40 VGPR,总共 40,960 VGPR,即 160 KiB。因此,每个 CU 寄存器文件的 96 KiB(37.5%)被浪费了。
正如您所见,如果因为超出 VGPR 容量而只有一个组适合 CU,那么最大尺寸的线程组很容易导致糟糕的 GPU 资源利用率。
在评估潜在的组大小配置时,考虑 GPU 资源生命周期非常重要。
GPU 会同时分配和释放线程组的所有资源。寄存器、LDS 和 wave 插槽都必须在组执行开始前分配,当组的最后一个 wave 完成执行时,所有组资源都会被释放。因此,如果只有一个线程组适合 CU,由于每个组必须等待前一个组完成才能开始,因此不会有分配和去分配的重叠。组内的 wave 会在不同时间完成,因为内存延迟是不可预测的。占用率会降低,因为下一个组的 wave 要等到前一个组的所有 wave 完成才能启动。
大型线程组倾向于使用大量 LDS。LDS 访问通过 barrier(GroupMemoryBarrierWithGroupSync,HLSL 中的 链接)进行同步。每个 barrier 会阻止执行继续,直到同一组的所有其他 wave 到达该点。理想情况下,CU 可以在等待 barrier 时执行另一个线程组。
不幸的是,在我们的例子中,只有一个线程组在运行。当 CU 上只有一个组运行时,barrier 会将所有 wave 限制在同一组有限的着色器指令集中。两个 barrier 之间的指令组合通常是单调的,因此组中的所有 wave 往往会同时加载内存。由于 barrier 阻止了向着色器后续独立部分前进,因此没有机会利用 CU 进行有用的 ALU 工作来隐藏内存延迟。
每个 CU 两个线程组可以显著减少这些问题。两个组倾向于在不同时间完成,并在不同时间遇到不同的 barrier,从而改善指令组合并显着减少占用率下降问题。SIMD 利用率更高,有更多的延迟隐藏机会。
我最近优化了一个 1024 线程组的着色器。最初它使用了 48 个 VGPR,所以每个 CU 只运行一个组。将 VGPR 使用量减少到 32 个,在一个平台上带来了 50% 的性能提升,而且没有其他优化。
每个 CU 两个组是最大尺寸线程组的最佳情况。然而,即使有两个组,占用率的波动也不是完全消除。在选择使用大型线程组之前,分析其优缺点非常重要。
解决问题的最简单方法是完全避免它。我提到的许多问题可以通过使用较小的线程组来解决。如果您的着色器不需要 LDS,根本没有必要使用更大的线程组。
当不需要 LDS 时,您应该选择 64 到 256 个线程之间的组大小。AMD 建议默认选择 256 个线程作为组大小,因为它最适合其工作分配算法。单 wave,64 个线程的组也有其用途:GPU 可以在 wave 完成后立即释放资源,并且 AMD 的着色器编译器可以移除所有内存 barrier,因为整个 wave 保证以同步的方式执行。具有高度波动循环的工作负载,例如我们 Claybook 游戏使用的球体追踪算法,最受益于单 wave 工作组。
然而,LDS 是计算着色器的一个引人注目且有用的特性,这是其他着色器阶段所没有的,并且正确使用它可以提供巨大的性能提升。将通用数据加载到 LDS 一次——而不是让每个线程执行单独的加载——可以减少冗余内存访问。高效的 LDS 使用可以减少 L1 缓存未命中和抖动,以及相应的内存延迟和流水线停顿。
当组大小减小时,使用 1024 个线程的组所遇到的问题会大大减少。512 个线程的组已经好很多了:每个 CU 最多可以容纳 5 个组。但是您仍然需要遵守严格的 32 VGPR 限制才能达到良好的占用率。
许多常见的后处理滤波器(如时间抗锯齿、模糊、膨胀和重建)需要了解元素的最近邻。通过使用 LDS 来消除冗余内存访问,这些滤波器可以获得显著的性能提升——在某些情况下高达 30%。

如果我们假设一个 2D 输入,并且每个线程负责着色一个像素,我们可以看到每个线程需要检索其初始值以及周围的八个像素。每个邻近像素也将需要线程的初始值。此外,中心值是每个邻近线程都需要的。这导致了很多冗余加载。在一般情况下,每个像素将被九个不同的线程需要。没有 LDS,该像素必须加载九次——每次加载一次,以便需要它的每个线程。
通过首先将所需数据加载到 LDS,然后用 LDS 加载替换所有后续的内存加载,我们可以显著减少对全局设备内存的访问次数以及缓存抖动的可能性。
当组内有大量可共享数据时,LDS 最有效。更大的邻域——因此,更大的组大小——意味着可以有更多有意义共享的数据,并进一步减少冗余加载。
假设一个 1 像素的邻域和一个正方形 2D 线程组。该组应加载组区域内的所有像素和一个 1 像素的边框以满足边界条件要求。边长为 X 的正方形区域需要 X^2 个内部像素和 4X+4 个边界像素。内部负载随平方增加,而边界开销——读取但未写入的像素——随线性增加。
一个 8x8 的组带有一个 1 像素的边框,包含 64 个内部像素和 36 个边界像素,总共 100 次加载。这需要 56% 的开销。
现在考虑一个 16x16 的线程组。负载包含 256 个像素,额外的开销是 68 个边界像素。虽然负载大小是原来的四倍,但开销只有 68 个像素,即 27%。通过将组的尺寸加倍,我们大大降低了开销。在最大可能的线程组大小 1024 个线程——一个 32x32 的正方形邻域——读取 132 个边界像素的开销仅占加载量的 13%。

3D 组的扩展性更好,因为组的体积比边界区域增加得更快。对于一个小的 4x4x4 组,负载包含 64 个元素,而表面边界(一个空的 6x6x6 立方体)需要 216 个元素,开销为 70%。然而,一个 8x8x8 的组,拥有 512 个内部像素和 488 个像素的边界区域,开销为 48%。邻域开销对于小型线程组大小来说非常大,但随着线程组大小的增大而改善。显然,大型线程组有其用武之地。
许多算法都需要多个通道。简单的实现将中间结果存储在全局内存中,消耗大量内存带宽。
有时,问题的每个独立部分或“岛屿”都很小,可以通过将中间结果存储在 LDS 中,将问题分解为多个步骤或通道。单个计算着色器执行所有必需的步骤,并在每个步骤之间将中间值写入 LDS。只有结果才会被写入内存。
物理求解器是这种方法的绝佳应用。迭代技术,如 Gauss-Seidel,需要多个步骤来稳定所有约束。问题可以分为“岛屿”:一个连接体的所有粒子分配给同一个线程组,并独立求解。后续通道可能处理身体间的相互作用,利用前一个通道计算出的中间数据。
具有大型线程组的着色器通常很复杂。达到 32 VGPR 的目标很难。以下是我多年来学到的一些技巧:
GCN 设备既有向量 (SIMD) 单元,它们为 wave 中的每个线程维护不同的状态,也有一个标量单元,它包含一个对 wave 内所有线程通用的单个状态。对于每个 SIMD wave,还有一个额外的标量线程在运行,它有自己的 SGPR 文件。标量寄存器包含整个 wave 的单个值。因此,SGPR 的片上存储成本低 64 倍。
GCN 着色器编译器会自动发出标量加载指令。如果在编译时知道加载地址是 wave 不变的(即,地址对 wave 中的所有 64 个线程都相同),编译器将发出标量加载,而不是让每个 wave 独立加载相同的数据。最常见的 wave 不变数据来源是常量缓冲区和字面值。基于 wave 不变数据的所有整数数学结果也是 wave 不变的,因为标量单元具有完整的整数指令集。这些标量指令与向量 SIMD 指令同时发出,并且通常在执行时间上是免费的。
计算着色器内置输入值 `SV_GroupID` 也是 wave 不变的。这一点很重要,因为它允许您将组特定数据卸载到标量寄存器,从而降低线程 VGPR 的压力。
标量加载指令不支持类型化缓冲区或纹理。如果您希望编译器将数据加载到 SGPR 而不是 VGPR,您需要从 `ByteAddressBuffer` 或 `StructuredBuffer` 加载数据。不要使用类型化缓冲区和纹理来存储对组通用的数据。如果您想从 2D/3D 数据结构执行标量加载,您需要自定义地址计算数学。幸运的是,由于标量单元具有完整的整数指令集,地址计算数学也将被高效地同时发出。
耗尽 SGPR 也是可能的,但不太可能。超出 SGPR 容量的最常见方法是使用过多的纹理和采样器。纹理描述符每个消耗八个 SGPR,而采样器每个消耗四个 SGPR。DirectX 11 允许使用单个采样器处理多个纹理。通常,单个采样器就足够了。缓冲区描述符仅消耗四个 SGPR。缓冲区和纹理加载指令不需要采样器,并且在不需要过滤时应使用它们。
示例:每个线程组都使用一个 wave 不变的矩阵(例如视图矩阵或投影矩阵)来转换位置。您使用四个类型化加载指令从 `Buffer` 加载 4x4 矩阵。数据存储到 16 个 VGPR。这已经浪费了一半的 VGPR 容量!相反,您应该从 `ByteAddressBuffer` 进行四次 `Load4`。编译器将生成标量加载,并将矩阵存储在 SGPR 而不是 VGPR 中。浪费了零个 VGPR!
齐次坐标在 3D 图形中很常用。在大多数情况下,您知道 W 分量是 0 或 1。在这种情况下,不要加载或使用 W 分量。它只会浪费一个 VGPR(每个线程)并生成更多 ALU 指令。
同样,只有投影才需要完整的 4x4 矩阵。所有仿射变换最多需要 4x3 矩阵。否则,最后一列是 (0, 0, 0, 1)。与完整的 4x4 矩阵相比,4x3 矩阵可以节省四个 VGPR/SGPR。
位打包是节省内存的有用方法。VGPR 是您最宝贵的内存——它们非常快,但数量也非常少。幸运的是,GCN 提供了快速的单周期位域提取和插入操作。使用这些操作,您可以在单个 32 位 VGPR 中高效地存储多个数据片段。
例如,2D 整数坐标可以打包为 16b+16b。HLSL 也有将两个 16 位浮点数打包或提取到 32 位 VGPR 的指令(`f16tof32` 和 `f32tof16`)。这些在 GCN 上是全速率的。
如果您的数据已经在内存中按位打包,请直接加载到 `uint` 寄存器或 LDS,直到使用时再解包。
GCN 编译器将 `bool` 变量存储在 64 位 SGPR 中,每个 wave 中的每个通道有一个位。VGPR 成本为零。不要使用 `int` 或 `float` 来模拟布尔值,否则此优化无效。
如果您拥有的布尔值超过 SGPR 的容量,可以考虑将 32 个布尔值按位打包到一个 VGPR 中。GCN 具有单周期位域提取/插入功能,可以快速操作位域。此外,您可以使用 `countbits()` 和 `firstbithigh()` / `firstbitlow()` 来对位域进行归约和搜索。通过掩码之前的位然后计数,可以有效地实现二进制前缀和。
布尔值也可以存储在总是正数的浮点数的符号位中。对于 GCN 来说,`abs()` 和 `saturate()` 是免费函数——它们是与它们使用的操作同时执行的简单输入/输出修改器——因此从符号位中检索存储的布尔值是免费的。不要使用 HLSL 的 `sign()` 内置函数来回收符号位。这会产生次优的编译器输出。测试一个值是否为非负值来确定符号位的值总是更快的。
编译器会尝试最大化数据加载到使用之间代码的距离,以便可以隐藏内存延迟,用中间指令来填充。不幸的是,数据必须保留在 VGPRs 中,从加载到使用。
动态循环可用于减少 VGPR 的生命周期。依赖于循环计数器的加载指令不能移出循环。VGPR 的生命周期被限制在循环体内部。
在 HLSL 中使用 [loop] 属性来强制实际循环。不幸的是,[loop] 属性并非完全万无一失。如果编译时已知所需迭代次数,着色器编译器仍然可以展开循环。
GCN3 引入了 16 位寄存器支持。Vega 通过以双倍速率执行 16 位算术运算来扩展这一点。支持整数和浮点数。两个 16 位寄存器将被打包成一个 VGPR。当您不需要完整的 32 位精度时,这是一种节省 VGPR 空间的简单方法。16 位整数非常适合 2D/3D 地址计算(资源加载/存储和 LDS 数组)。16 位浮点数在后期处理过滤器等场景中非常有用,尤其是在处理 LDR 或色调映射后的数据时。
当同一组中的多个线程加载相同数据时,您应该考虑将数据加载到 LDS 中。这可以在加载指令计数和 VGPR 数量方面带来巨大的节省。
LDS 也可用于临时存储当前不需要的寄存器。例如:一个着色器在开始时加载并使用一部分数据,并在最后再次使用该数据。然而,VGPR 的峰值出现在着色器的中间。您可以将这些数据临时存储到 LDS,并在需要时将其加载回来。这会在关键时刻(峰值时)减少 VGPR 的使用。
Second Order Ltd 的联合创始人 Sebastian Aaltonen 探讨了如何优化使用大型线程组的计算着色器的 GPU 占用率和资源使用情况。
Sebastian Aaltonen(Second Order 联合创始人)的客座文章。文中涵盖了在使用 AMD Ryzen Threadripper 处理器时优化引擎构建和资产生产。