通过降低指令缓存未命中率提高GPU性能
通过降低指令缓存未命中率提高GPU性能
GPU性能优化是一个复杂而精细的过程,其中指令缓存的管理是关键环节之一。本文通过一个基因组学应用案例,深入探讨了如何通过减少指令缓存未命中率来提升GPU计算效率。文章详细介绍了问题的识别、分析和解决过程,为从事高性能计算和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)。
图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显示了套件的结果。
图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多,从而导致更多的延迟隐藏。
图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开发者工具教程。