通过减少指令缓存缺失来提高GPU性能

GPU是专门为高速处理大量数据而设计的。它们有大量的计算资源,称为流多处理器(SMs),以及一系列的设施来保持它们的数据:高带宽到内存,相当大的数据缓存,如果一个活动团队的数据用完,可以切换到其他工作团队(warp),而不会有任何开销。

然而,数据饥饿仍然可能发生,许多代码优化都集中在这个问题上。在某些情况下,短信并不需要数据,而是需要指令。这篇文章介绍了一个由于指令缓存丢失而导致GPU工作负载变慢的调查。它描述了如何识别这个瓶颈,以及消除瓶颈以提高性能的技术。

认识到问题

这项研究的起源是基因组学领域的一个应用,其中许多小的、独立的问题与DNA样本的小片段与参考基因组的比对有关,必须得到解决。背景是众所周知的 史密斯-沃特曼算法 (但它本身对讨论并不重要)

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

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

GPU的工作被分成称为线程块的块,一个或多个线程块可以驻留在一个SM上。如果一些短信收到的线程块比其他短信少,那么它们将耗尽工作,并且在其他短信继续工作时必须空闲。

用线程块完全填充所有SMs构成一个波。NVIDIA insight Compute忠实地报告每个SM的波数。如果这个数字恰好是100.5,则意味着并非所有的SMs都有相同的工作量要做,有些SMs被迫空闲。然而,这种分布不均的影响并不大。

大多数情况下,SMs上的负载是平衡的。例如,如果波的数量只有0.5,情况就会改变。在更大比例的时间里,SMs经历了不均匀的工作分配,这被称为尾部效应。

解决尾部效应

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

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

NVIDIA insight Compute关于这四种工作负载规模的综合报告揭示了这一情况。在名为Warp State的部分中,该部分列出了线程无法进行进程的原因,No Instruction的值随着工作负载大小的增加而显著增加(图1)。

柱状图的截图,其中“无指令”的值导致最多的摊位。

图1所示。从NVIDIA Nsight综合计算报告中可以得出四种工作负载大小的Warp失速原因

无指令意味着短信不能足够快地从内存中输入指令。长记分牌表示SMs不能足够快地从内存中输入数据。及时获取指令是非常重要的,GPU提供了许多站点,在这些站点中,指令可以在获取后放置,以使它们靠近SMs。这些工作站被称为指令缓存,它们的级别甚至比数据缓存还要高。

指令缓存缺失明显增加如此之快的事实,正如由于无指令而导致的warp stall的快速增长所证明的那样,意味着以下几点:

并不是代码中最繁忙部分的所有指令都适合缓存。

随着工作负载大小的增加,对更多不同指令的需求也会增加。

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

当一个经线因任何原因停滞时,另一个也位于SM上的经线可以接管。每个warp都可以独立于其他warp执行自己的指令流。

在这个程序的主内核开始时,在每个SM上运行的warp基本上是同步的。他们从第一次指导开始,然后一直坚持下去。 但是,它们没有显式同步。

随着时间的推移,翘曲机轮流空转和执行,它们执行的指令越走越远。这意味着随着执行的进行,越来越多的不同指令必须处于活动状态,而这又意味着指令缓存更频繁地溢出。指令缓存压力会增加,并且会发生更多的丢失。

解决问题

warp指令流的逐渐漂移是无法控制的,除非通过同步这些流。但是同步通常会降低性能,因为它需要在没有基本需求的情况下等待彼此。

但是,您可以尝试减少总体指令占用,以便减少指令缓存溢出的发生频率,或者根本不发生溢出。

所讨论的代码包含一组嵌套循环,并且大多数循环是展开的。展开通过使编译器能够执行以下操作来提高性能:

重新排序(独立)指令以更好地调度。

消除一些可以由循环的连续迭代共享的指令。

减少分支。

为循环的不同迭代中引用的相同变量分配不同的寄存器,以避免必须等待特定的寄存器可用。

展开循环有很多好处,但它确实增加了指令的数量。它还倾向于增加使用的寄存器的数量,这可能会降低性能,因为同时驻留在SMs上的warp可能更少。这种减少的曲速占用伴随着更少的延迟隐藏。

内核的两个最外层循环是焦点。实际展开最好留给编译器,它有无数的启发式方法来生成好的代码。也就是说,用户通过在循环开始前使用提示(在C/ c++中称为pragmas)来表达展开的预期好处。

它们的形式如下:

#pragma展开X

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

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

0 =根本没有展开程序。

1 =没有任何数字的unroll pragma (canonical)。

n 大于1 =一个正数,表示以 n 次迭代为组展开。

#pragma unroll

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

绘制每个工作负载大小的代码性能的图表。对于展开因子的每个实例,可执行文件的大小以500 KB为单位显示。

图2。 Smith-Waterman代码在不同工作负载大小和不同循环展开因素下的性能

顶部水平轴显示最外层循环(顶层)的展开因子。底部的水平轴显示了第二级循环的展开因子。四条性能曲线(越高越好)上的每个点对应于两个展开因子,一个用于水平轴上所示的最外层循环。

图2还显示了对于unroll因子的每个实例,可执行文件的大小以500 KB为单位。虽然期望看到可执行文件的大小随着每一个更高级别的展开而增加,但情况并非始终如此。展开pragmas是一些提示,如果它们被认为不是有益的,编译器可能会忽略它们。

与代码的初始版本(由标记为A的椭圆表示)相对应的度量用于顶级循环的规范展开,而不用于第二级循环的展开。代码的异常行为是明显的,其中较大的工作负载大小导致较差的性能,由于增加指令缓存丢失。

在下一个单独的实验中(用标记为B的椭圆表示),在整套运行之前尝试,最外层的两个环都没有展开。现在异常行为消失了,更大的工作负载带来了预期的更好的性能。

但是,绝对性能会降低,特别是对于原始工作负载大小而言。NVIDIA Nsight Compute揭示的两种现象有助于解释这一结果。由于指令内存占用更小,对于所有大小的工作负载,指令缓存丢失都减少了,这可以从No instruction warp stall(未描述)下降到几乎可以忽略不计的值这一事实中推断出来。然而,编译器为每个线程分配了相对大量的寄存器,因此可以驻留在SM上的warp的数量不是最优的。

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

曲速状态图的条形图,无指令曲速的值可以忽略不计。

图3。从NVIDIA Nsight Compute综合报告中获得最佳展开因素的四种工作负载大小的翘曲失速原因

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

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

如图2中的虚线所示,总指令内存占用大小并不是指令缓存压力的准确度量,因为它们可能包含只执行少量次的代码段。最好是研究代码中“最热”部分的总大小,这可以通过在NVIDIA Nsight Compute的源视图中寻找指令执行度量的最大值来识别。

场景A、场景B、场景C分别为39360、15680、16912。显然,与场景A相比,场景B和C的热指令内存占用要少得多,从而导致较少的指令缓存压力。

结论

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

尝试使用不同的关于循环展开的编译器提示,以获得具有良好warp占用率和减少指令缓存丢失的最佳代码,这可能是值得的。

PHP Code Snippets Powered By : XYZScripts.com