数据中心/云端

通过降低指令缓存未命中率提高 GPU 性能

GPU 专为高速处理大量数据而设计。GPU 具有称为流多处理器 (SM) 的大量计算资源,以及一系列可为其提供数据的设施:高带宽内存、高大小数据缓存,以及在活跃的线程束用完时切换到其他线程束的能力,而不会产生任何开销。

然而,数据乏现象可能仍会发生,许多代码优化都集中在这个问题上。在某些情况下,SMs 不是数据乏,而是指令乏。本文介绍了对 GPU 工作负载的调查,该工作负载因指令缓存丢失而经历了速度放慢。本文介绍了如何识别此瓶颈,以及消除瓶颈以提高性能的技术。

识别问题

这项研究的起源是基因组学领域的应用程序,在该领域中,必须解决与将 DNA 样本的小部分与参考基因组进行比对相关的许多小的独立问题。背景是众所周知的 Smith-Waterman 算法(但这本身对讨论并不重要)。

在强大的 NVIDIA H100 Hopper GPU 上,拥有 114 个 SM 的中型数据集上运行该程序显示出了良好的前景。用于分析 GPU 上程序执行情况的 NVIDIA Nsight Compute 工具证实了 SM 非常忙碌于有用的计算,但存在一个障碍。

构成整体工作负载的许多小问题(每个问题都由自己的线程处理)可以在 GPU 上同时运行,因此并非所有计算资源都一直被充分利用。这表示为少量非整数波数。

GPU 工作被分割成称为线程块的块,其中一个或多个线程块可以驻留在一个 SM 上。如果某些 SM 收到的线程块少于其他 SM,它们将用完工作,必须空闲,而其他 SM 继续工作。

用线程块完全填充所有 SM 构成一个波。NVIDIA Nsight Compute 会完整报告每个 SM 的波数。如果该数字恰好为 100.5,则表示并非所有 SM 的工作量相同,一些 SM 不得不空闲。但是,不均匀分布的影响并不大。

大多数时候,SM 上的负载是平衡的。例如,如果波的数量仅为 0.5,这种情况就会发生变化。在更大比例的时间里,SM 会经历不均匀的工作分布,称为尾部效应

解决尾部效应

这种现象正是基因组学工作负载中出现的现象。波的数量只有 1.6。显而易见的解决方案是让 GPU 做更多的工作(更多线程,导致每个线程有 32 个线程的更多线程束),这通常不成问题。

原始工作负载相对较小,在实际环境中,必须完成更大的问题。但是,通过将子问题数量增加一倍、三倍和四倍来增加原始工作负载,会导致性能下降而非改善。这是什么原因导致这种结果?

这四种工作负载大小的 NVIDIA Nsight Compute 合并报告说明了相关情况。在名为 Warp State 的部分中,列出了线程无法进行处理的原因,无指令值随着工作负载大小而显著增加(图 1)。

Screenshot of of bar chart, where the value for “No Instruction” is causing the most stalls.
图 1.NVIDIA Nsight Compute 合并报告中四种工作负载大小的扭曲停滞原因

无指令意味着SM无法从内存中获得足够快的指令。长记分牌表明SM无法从内存中获得足够快的数据。及时获取指令非常关键,因此GPU提供了许多工作站,在获取指令后,可以在这些工作站放置指令,以使指令保持在SM附近。这些工作站称为指令缓存,其级别甚至高于数据缓存。

由于无指令导致线程束停滞现象的快速增长,指令缓存丢失显然也会快速增加,这表明以下几点:

  • 并非代码最繁忙部分的所有指令都适合该缓存。
  • 随着工作负载大小的增加,对更多不同指令的需求也在增加。

后者的原因有些微妙。由warp组成的多个线程块同时驻留在SM上,但并非所有warp同时执行。SM内部分为四个分区,每个分区通常可以在每个时钟周期执行一条warp指令。

当 warp 由于任何原因停止运行时,同样驻留在 SM 上的另一个 warp 可以接管。每个 warp 都可以独立于其他 warp 执行自己的指令流。

在此程序的主内核开始时,在每个 SM 上运行的线程束大多数都是同步的。它们从第一个指令开始并持续不断。但是,它们没有明确同步。

随着时间的推移,线程束轮流闲置和执行,它们在执行指令方面的距离越来越远。这意味着随着执行的进展,必须激活越来越多的不同指令集,这反过来意味着指令缓存溢出的频率更高。指令缓存压力增加,并且丢失次数更多。

解决问题

除非通过同步流,否则无法控制 warp 指令流的逐渐分离。但同步通常会降低性能,因为在没有基本需求的情况下,它需要 warp 相互等待。

但是,您可以尝试减少整体指令占用空间,以减少指令缓存溢出的频率,甚至可能根本不会发生溢出。

相关代码包含一个嵌套循环集合,大多数循环是展开的。展开通过让编译器执行以下操作来提高性能:

  • 重新排序 (独立) 指令以更好地调度。
  • 删除一些可以通过循环的连续迭代共享的指令。
  • 减少分支。
  • 将同一变量在不同循环迭代中的引用分配给不同的寄存器,以避免等待特定寄存器变得可用。

展开循环具有许多好处,但它确实会增加指令数量。它还往往会增加使用的寄存器数量,这可能会降低性能,因为 SM 上同时驻留的线程束较少。这种减少的线程束占用降低了延迟隐藏能力。

内核的两个最外围的循环是重点。实际展开最好由编译器来完成,编译器有大量启发式算法来生成良好的代码。那就是说,用户通过在循环顶部之前使用提示(在 C/C++ 中称为 pragmas)来表达展开预期的好处。

它们采用以下形式:

#pragma unroll X 

在哪里X可以为空 (规范展开),编译器只被告知展开可能是有益的,但没有给出要展开多少次迭代的任何建议。

为方便起见,我们对展开系数采用了以下表示法:

  • 0 = 完全无需卸载。
  • 1 = 不含任何数字的展开实用程序 (规范)。
  • n大于 1 = 正数,表示以 n 次迭代组展开。
#pragma unroll (n) 

下一个实验包括一组运行,其中代码中两个最外围循环的unroll factor在 0 和 4 之间变化,从而为四种工作负载大小的每个级别生成性能图。不需要展开更多,因为实验表明编译器不会为该特定程序的更高unroll factor生成不同的代码。图 2 显示了套件的结果。

A graph plotting code performance for each of the workload sizes. For each instance of unroll factors, the size of the executable is shown  in units of 500 KB.
图 2.Smith-Waterman 代码在不同工作负载大小和不同循环展开系数下的性能表现

顶部水平轴显示最外层循环(顶层)的unroll factors。底部水平轴显示二级循环的unroll factors。四条性能曲线中任何一条(越高越好)上的每个点都对应两个unroll factors,每个最外层循环各对应一个系数,如水平轴所示。

图 2 还显示了每个展开因子实例的可执行文件大小 (以 500 KB 为单位)。虽然预期可执行文件大小会随着展开级别的提升而增加,但情况并非如此。展开pragma 是一些提示,如果编译器认为这些提示不有益,则可能会被编译器忽略。

与代码初始版本(由标记为 A 的椭圆表示)对应的测量用于规范展开顶层循环,而非展开二级循环。代码的异常行为显而易见,由于指令缓存丢失增加,工作负载规模越大,性能越差。

在下一个单独的实验(由标记为 B 的椭圆表示)中,在全套运行之前尝试了既不展开最外围的循环。现在,异常行为消失了,更大的工作负载大小会导致预期的性能更好。

但是,绝对性能降低,尤其是对于原始工作负载大小而言。NVIDIA Nsight Compute 揭示的两种现象有助于解释这一结果。由于指令内存占用较小,各种大小的工作负载的指令缓存丢失都减少了,这可以从无指令线程束停滞(未说明)已下降到几乎可以忽略不计的值来推断。但是,编译器为每个线程分配了相对较多的寄存器,因此可以驻留在 SM 上的线程束数量并非最佳。

对展开系数进行全面扫描表明,标记为 C 的椭圆中的实验是众所周知的亮点。它对应于顶层循环的不展开,以及第二层循环的 2 倍展开。NVIDIA Nsight Compute 仍然显示无指令线程束停滞(图 3)的值可以忽略不计,并且每个线程的寄存器数量减少,因此 SM 上可以容纳的线程数比实验 B 多,从而导致更多的延迟隐藏。

Bar chart of the Warp State graph, with negligible values for No Instruction warp stalls.
图 3.NVIDIA Nsight Compute 组合报告中四种工作负载大小的线程束停滞原因(最佳展开系数)

虽然最小工作负载的绝对性能仍然落后于实验 A,但差别不大,而且更大的工作负载的表现越来越好,从而在所有规模的工作负载中实现最佳的平均性能。

对 NVIDIA Nsight Compute 报告中三种不同的展开场景 (A、B 和 C) 的进一步检查阐明了性能结果。

如图 2 中的虚线所示,总指令显存占用大小并不能准确衡量指令缓存压力,因为它们可能包含仅执行几次的代码段。最好研究代码中“最热门”部分的聚合大小,这可以通过在 NVIDIA Nsight Compute 的源视图中查找“Instructions Executed”指标的最大值来识别这些部分。

对于场景 A、场景 B 和场景 C,这些大小分别为 39360、15680 和 16912。显然,与场景 A 相比,场景 B 和场景 C 的热指令内存占用空间大大降低,从而降低指令缓存压力。

结束语

指令缓存丢失会导致指令占用空间较大的核函数的性能下降,而这通常是由大量循环展开引起的。当编译器通过pragma负责展开时,它对代码应用启发式算法以确定最佳实际展开级别,这是必然复杂的,而且程序员并不总是可以预测的。

不妨尝试不同的编译器循环展开提示,以获得具有良好线程束占用和减少指令缓存丢失的最佳代码。

立即开始使用 Nsight Compute。有关更多信息和教程,请参阅 Nsight 开发者工具教程

 

Tags