**RDNA 4** 的 "Out-of-Order" 内存访问探究
Chips and Cheese
RDNA 4 带来了一系列内存子系统的增强。其中,有一张幻灯片格外引人注目,因为它涉及到了乱序 (out-of-order) 内存访问。根据幻灯片,RDNA 4 允许来自不同 shaders 的请求以乱序方式完成,并且为内存请求添加了新的乱序队列。
跨 Wave 的乱序内存访问
在 RDNA 4 之前,AMD 显然在内存子系统中存在伪依赖的情况。一个 wave 可能会等待另一个 wave 的内存 loads 完成。"wavefront"、"wave" 或 "warp" 在 GPU 上大致相当于 CPU 线程。 它有自己的寄存器状态,并且可以与其他 waves 失去同步。 每个 wave 的指令独立于其他 waves 中的指令,只有极少数例外 (例如原子操作)。
在 RDNA 3 中,数据返回的顺序非常严格,实际上,即使较晚发出的请求的数据已经准备好,也不允许它超过较早发出的请求。 Navi 4 Architecture Deep Dive, Andrew Pomianowski, CVP, Silicon Design Engineering (AMD)
多线程编程的一个基本原则是,除非通过锁或其他机制来实现,否则无法保证线程之间的执行顺序。这使得多线程性能扩展成为可能。AMD 的幻灯片让我感到惊讶,因为没有理由内存读取会成为一个例外。我反复观看了视频几次,并盯着幻灯片看了一会儿,想看看他们是否真的是这个意思。他们显然是这个意思,但我仍然不敢相信自己的眼睛和耳朵。所以我花时间为此制作了一个测试。
测试
AMD 的幻灯片描述了一个场景,其中一个 wave 的缓存未命中 (cache misses) 阻止了另一个 wave 快速地从缓存命中 (cache hits) 中获取数据。造成缓存未命中很容易。我可以通过一个具有随机模式的大数组进行指针追踪 ("wave Y")。类似地,我可以将访问限制在一个小的内存 footprint 内,以获得缓存命中 ("wave X")。但同时做这两件事是有问题的。Wave Y 可能会驱逐 wave X 使用的数据,从而导致 wave X 上的缓存未命中。
与其追求缓存命中和未命中,我通过查看在一个 wave 中等待内存访问是否会错误地等待另一个 wave 发出的内存访问来进行测试。我的 "wave Y" 基本上是一个内存延迟测试,并进行固定次数的访问。每次访问都依赖于前一次的结果,我让 wave 通过一个 1 GB 数组进行指针追踪,以确保缓存未命中。我的 "wave X" 在每次循环迭代中进行四次独立的内存访问。然后它消耗加载的数据,这意味着等待数据从内存中到达。
一旦 wave Y 完成所有访问,它就会在本地内存中设置一个标志。Wave X 会尽可能多地进行内存访问,直到看到该标志被设置,之后它会写出它的 "score" 并终止。我在同一个 workgroup 中运行这两个 waves,以确保它们共享一个 WGP,因此尽可能多地共享内存子系统。将两个 waves 保留在同一个 workgroup 中还可以让我将 "finished" 标志放置在本地内存中。Wave X 必须在每次迭代中检查该标志,最好让标志检查不要通过 wave Y 正忙于污染的相同缓存。
如果每个 wave X 的访问都受到 wave Y 的延迟影响,我应该看到两者大致相同的访问次数。但在 RDNA 3 上,我看到 wave X 的访问次数比 wave Y 多,正好是 wave X 上的循环展开因子。AMD 的编译器静态调度指令,并在等待数据之前发出所有四个访问。然后它使用 s_waitcnt vmcnt(...)
指令等待加载完成。
由 AMD 的编译器为 wave X 生成的带注释的 RDNA 3 汇编代码。请注意,将循环展开为每次迭代使用四个内存访问,可以让编译器在等待这些访问之前发出这四个访问。
vmcnt
跟踪的访问始终按顺序返回,允许编译器通过等待 vmcnt
递减到某个值或更低来等待特定的访问。在 wave Y 中,我使所有访问都依赖于彼此,因此编译器只等待 vmcnt
达到 0。
wave y 的带注释的 RDNA 3 汇编代码,为了完整性。
在 RDNA 3 上,s_waitcnt vmcnt(...)
似乎不仅等待来自其自身 wave 的请求完成,还等待来自其他 waves 的请求完成。这解释了为什么 wave X 每次访问 wave Y 时都会进行四次访问。如果我展开循环更多,让编译器在等待之前调度更多独立的访问,则该比率会增加以匹配展开因子。
在 RDNA 4 上,两个 waves 不关心对方在做什么。这才是应该有的样子。RDNA 4 还显示出更多的运行间差异,这也是预期的,因为在这个测试中缓存行为是高度不可预测的。我对结果感到惊讶,但这令人信服地证明了 AMD 确实在 RDNA 3 和更早的 GPU 架构上存在伪跨 wave 内存延迟。我还测试了 Renoir 的 Vega iGPU,并看到了与 RDNA 3 相同的行为。
在一个简单的层面上,你可以想象来自 shaders 的请求进入一个队列等待服务,并且许多这些请求可能正在进行中。 Navi 4 Architecture Deep Dive, Andrew Pomianowski, CVP, Silicon Design Engineering (AMD)
AMD 的演示暗示 RDNA 3 和更早的 GPUs 有多个 waves 共享一个内存访问队列。如上所述,自 GCN 以来,AMD GPU 使用软件等待的硬件计数器来处理内存依赖关系。通过保持 vmcnt
按顺序返回,编译器可以等待产生下一条指令所需数据的特定加载,而无需等待 wave 具有的每个其他挂起的加载。RDNA 3 和之前的 AMD GPU 可能有一个共享的内存访问队列,每个条目都标有其 wave 的 ID。当每个内存访问按顺序离开队列时,硬件会递减其 wave 的计数器。
也许 RDNA 4 将共享队列划分为每个线程的队列。这将与 AMD 幻灯片上的要点一致,即 RDNA 4 为内存请求引入了 "additional out-of-order queues"。或者,也许 RDNA 4 保留了一个共享队列,但可以乱序地耗尽条目。这将需要跟踪额外的信息,例如内存访问是否是其 wave 的最旧的访问。
其他厂商也会发生这种情况吗?
共享内存访问队列并按顺序返回数据似乎是一种自然的硬件简化。 这引发了一个问题:Intel 和 Nvidia 的 GPU 架构是否也有类似的限制。
Intel 的 Xe-LPG 没有伪跨 wave 内存依赖项。在 Meteor Lake 的 iGPU 上运行相同的测试显示出变化,具体取决于两个 waves 的最终位置。如果 wave X 和 wave Y 在具有共享指令控制逻辑的 XVE 上运行,则 wave X 的性能低于其他情况。无论如何,很明显 Xe-LPG 不会强制一个 wave 等待另一个 wave 的访问。Intel 随后的 Battlemage (Xe2) 架构显示出类似的行为,并且同样适用于 Intel 之前的 Gen 9 (Skylake) graphics。
我还检查了生成的汇编代码,以确保 Intel 的编译器没有进一步展开循环。
Meteor Lake 的 iGPU 上为 Wave X 生成的汇编代码。UGM = untyped global memory,SLM = shared local memory。其余的是微不足道的,只需记住 Intel GPUs 具有充满寄存器的寄存器......没关系
Nvidia 的 Pascal 具有不同的行为,具体取决于 waves 在 SM 中的位置。每个 Pascal SM 都有四个分区,这些分区成对排列,共享一个纹理单元和一个 24 KB 纹理缓存。首先将 waves 分配给配对中的分区。就好像分区编号为 [0,1]-> tex,[2,3]-> tex。在同一子分区配对中的 Waves 存在伪依赖问题。 显然,除了纹理单元之外,它们还共享一些通用的加载/存储逻辑,因为我在此测试中没有接触纹理。
如果一个 wave 与另一个 wave 的偏移不是 4 的倍数或 4 加 1 的倍数,则它没有伪依赖问题。在 GTX 1660 Ti 上测试的 Turing 也没有问题。
更好的 Nonblocking Loads
除了消除伪跨 wave 延迟之外,AMD 还改进了 wave 内的内存请求处理。与有序 (in-order) CPU 内核 (例如 Arm 的 Cortex A510) 类似,GPU 可以在等待内存访问时执行独立的指令。线程仅在尝试使用内存访问的结果时才会stall。几十年来,GPU 一直在这样做,尽管实现细节有所不同。Intel 和 Nvidia 的 GPU 使用软件管理的 scoreboard。AMD 从 GCN 开始使用挂起请求计数器。
RDNA 4 使用相同的方案,但将 vmcnt
类别拆分为多个计数器。一个线程可以交错全局内存、纹理采样和光线追踪相交测试请求,并分别等待它们。这使编译器能够更灵活地将工作移到等待内存访问完成之前。对 AMD 幻灯片的另一种解释是,每个计数器对应于一个单独的队列,每个队列都具有跨 waves 的乱序行为 (但可能具有 wave 内的有序行为)。
来自 3DMark 的光线追踪功能测试的 RDNA 4 汇编代码示例,显示了一个基本块分别等待其他基本块发出的全局内存加载和纹理采样请求。
类似地,lgkmcnt
分为用于标量内存加载的 kmcnt
和用于 LDS 访问的 dscnt
。标量内存加载是乱序的,这意味着编译器必须等待所有标量内存加载完成 (kmcnt=0
或 lgmkcnt=0
),然后才能使用来自任何挂起的标量内存加载的结果。在 RDNA 4 上,编译器可以交错标量内存和 LDS 访问,而无需等待 lgkmcnt=0
。
Intel 和 Nvidia 的 GPU 使用软件管理的 scoreboards。 scoreboard 条目可以由任何指令设置或等待,无论内存访问类型如何。因此,RDNA 4 的优化不适用于那些其他 GPU 架构。 Intel/Nvidia 方法的一个代价是,利用大型内存请求队列需要相应的大型 scoreboard。AMD 可以将计数器扩展一位,并将一个 wave 可以使用的队列条目数量增加一倍。
总结
与 RDNA 3 相比,RDNA 4 的内存子系统增强令人兴奋,并提高了各种 workloads 的性能。AMD 特别指出了光线追踪 workloads 的好处,其中 traversal 和结果处理可能在同一个 WGP 上同时发生。Traversal 涉及指针追踪,而结果处理可能涉及更适合缓存的数据查找和纹理采样。打破跨 wave 内存依赖性可以防止这些任务中不同的内存访问模式相互延迟。
可能这在 rasterization 中不是问题,因为分配给 WGP 的 waves 可能在非常接近的像素上工作。这些 waves 可能会采样相同的纹理,甚至在同一纹理中彼此非常接近地进行采样。如果一个 wave 在缓存中未命中,其他 wave 也可能未命中。
分解 vmcnt
和 lgmkcnt
可能也有助于光线追踪。光线追踪 shaders 在 traversal 期间发出 BVH 相交和 LDS 堆栈管理请求。然后,他们可能会在结果处理期间采样纹理或访问全局内存缓冲区。让编译器能够灵活地交错这些请求类型并仍然等待特定请求是一件好事。
但 RDNA 4 处理内存依赖关系的方案与多年前的 GCN 的方案没有根本的不同。虽然实现细节有所不同,但 RDNA 4、GCN 以及 Intel 和 Nvidia 的 GPU 都可以吸收缓存未命中,而不会立即 stall 一个线程。每个 GPU 制造商都提高了他们这样做的能力,无论是使用更多的 scoreboard 令牌还是更多的计数器。RDNA 4 确实可以进行 Cortex A510 风格的 nonblocking loads,但这在 GPU 领域远非一项新功能。
解决伪跨 wave 依赖项也不是什么新鲜事。Nvidia 在 Turing 中具有 "out-of-order" 跨 wave 内存访问处理,并且可能也包括他们更新的架构。Intel 至少早在 Gen 9 (Skylake) graphics 就具有相同的功能。因此,RDNA 4 的 "out-of-order" 内存子系统增强最好被视为 generational 调整,而不是新的改变游戏规则的技术。
尽管如此,AMD 的工程师应该为实现它们而受到赞扬。RDNA 4 可以说是自 2019 年 RDNA 推出以来 AMD GPU 内存子系统发生的最重大变化。我很高兴看到该公司继续改进其 GPU 架构,并使其更适合新兴的 workloads,例如光线追踪。