9. MUSA性能优化
在本章中,我们将讲解一些 MUSA 中常用的计算优化和访存优化的方法,同时会介绍性能调优的相关方法和工具。最后,我们还会提供 Reduction、GEMM 等优化实例供参考和学习。
9.1. 核函数优化
9.1.1. 并行度优化
9.1.1.1. 最大化计算并行度
为了最大化计算并行度,首先我们需要选择合适的算法。对于给定的应用场景,不同算法的计算并行度可能会有很大差异。有些天然适合并行计算的场景,例如 reduce;有些场景的常规算法则不适合进行并行实现,例如 sort,这时我们则需要探索其适合并行实现的算法,比如可以使用 双调排序 Bitonic sort 来进行并行实现;对于一些无法直接进行并行实现的复杂场景,我们可以对其进行拆分,选取部分功能进行并行实现,以提升整体的性能。
9.1.1.2. 减少分支
在一个 thread block 中,由于 if、switch、for 和 while 等分支控制语句的使用,会导致不同线程的执行路径产生 divergence,从而导致不同线程在某些时刻需要执行不同的指令。这种情况会导致线程之间产生额外的等待开销,影响程序的性能。
-
循环展开
- 采用循环展开可以减少循环开销和分支,从而提高性能。循环展开可以手动进行,也可以使用编译器优化来自动完成。循环展开通常可以提高计算性能,但需要注意循环展开后指令的条数,指令条数过多容易造成 Instruction Cache Misses,反而会降低核函数的性能。
#pragma unrollfor (int i = 0; i < 10; ++i){// ...} -
我们还可以使用一些技巧来避免分支,例如使用条件运算符(ternary operator)代替 if-else 语句。
9.1.1.3. 向量化数据读取
向量化是一种针对数据并行的优化技巧。通过向量化数据读取,每个线程可以同时读取多个相邻的数据元素,从而减少读取操作和访存延迟,提高程序的效率。
9.1.2. 核函数执行配置优化
MUSA 定义的线程结构分为三级:grid、block 和 thread,采用这种层次化的线程结构是为了能有效的与 GPU 层次化的硬件结构相对应。在进行核函数的实现时,合理的核函数配置能最大限度地发挥硬件的性能。
9.1.2.1. 最大化硬件占用率
为了获取更好的性能,最大化硬件利用率是关键之一。在核函数的执行过程中,大量的 warp 可以提供高度的并行性,使得处理器可以借助 warp 切换来进行访存等延时隐藏。理论上warp数越多,并行度会越高,对于延时的隐藏效果会更好。但在实际应用中,并行度会受硬件资源总数、每个线程所需的资源数量(寄存器等)以及每个block所需的资源数量(shared memory等)决定的。例如,在 S80 显卡中,一个 MP 提供了 28KB 的 shared memory,若单个 block 使用 4KB 的 shared memory,在仅考虑 shared memory 的约束下,则最多可以同时运行 7 个 block。
active warps 个数受限于木桶理论,即受制于各个资源的最短板。因此在核函数的编写中,需要综合考虑单个线程的寄存器使用个数、单个block的shared memeory使用大小等,确保 active warps 的个数在一个合理的区间,以保证运行时有足够的 warp 数量以隐藏延时,最大化硬件占用率。
9.1.2.2. block size配置策略
block size 应设置为 warp size 的整数倍(SUDI 和 QY 架构中,warp size 为 128),因为如果 block size 不是 warp size 的整数倍,那么在执行时会导致一些 warp 只有部分线程被使用,从而浪费计算资源。将 block size 设置为 warp size 的整数倍可以最大化核函数的并行度以提升效率。
- 一般情况下,128、256、512 和 1024 可以作为候选的 block size 以获取较优的性能。
- GPU 一般会包含多个 MP,因此我们在进行核函数配置时,一半还需要考虑 block 的总个数,至少保证每个 MP 有一个 block 去执行。理想的状态是,active warps 的数量足够多,使得 GPU 可以通过 warp 切换隐藏延时。
如我们上面描述的,选择合适的 block size 有许多需要考虑的因素,这个过程需要编程人员具有一定的 GPU 编程经验。幸而还有一种适合新手的方法——auto fine-tune block size。具体来说,对于给定的核函数,该方法通过试验不同的 block size 大小,并利用如吞吐量、延时等性能监测指标来评估性能,从而确定较优的 block size 大小。很多开源的项目中都集成了 atuo fine-tune block size 的功能,例如 MNN、TNN 以及 ROCm。
需要注意的是,如果单个 block 所需的寄存器或 shared memory 超过单个 MP 提供的最大范围时,核函数会启动失败。
9.1.2.3. 多个核函数并发执行
在某些应用场景下,我们可以将无依赖关系的多个核函数分发至不同的 stream,使多个独立的核函数可以同时执行,以此来提高硬件利用率。
9.1.3. 空间换时间
9.1.3.1. double buffer
double buffer技术是一种通过交替使用两个缓冲区来实现数据传输和计算并行的技术。
- 在GPU计算中,通常需要进行数据传输和计算两个步骤,double buffer技术可以将这两个步骤并行执行,从而提高计算效率。具体来说,double buffer技术需要使用两个缓冲区来存储数据,一个缓冲区用于计算,另一个缓冲区用于数据传输。在计算时,使用其中一个缓冲区进行计算,同时在另一个缓冲区进行数据传输。计算完成后,再切换缓冲区,继续进行计算和数据传输。使用double buffer技术的优点是可以在数据传输和计算之间实现并行,提高计算效率。此外,double buffer 还可以减少 GPU 的空闲时间,提高 GPU 的利用率。
- double buffer 的缺点是需要额外的存储空间,我们需要平衡存储空间和计算效率的关系。
9.1.3.2. img2col
img2col是采用矩阵乘来实现卷积计算的步骤之一。具体做法为:
- 将4维的 feature map 和 weight 提前进行 img2col 转化为2维的矩阵,并使用额外的空间存储 img2col 的结果,这样在矩阵乘阶段我们可以直接使用。img2col 带来的好处是提高了矩阵乘阶段的数据访问局部性,降低了数据访问延迟,从而提高计算效率。当然,img2col 会引入一定的计算(weight 的 img2col 可以提前进行,而 feature map 的 img2col 则只能在推理阶段进行)和内存开销,我们需要平衡计算和内存消耗的开销。
9.1.4. 精度选择
GPU 对不同精度的数据具有不同的处理速度。针对不同的应用场景,我们可以在可接受的计算精度范围内选择较低的数据精度以提升应用的性能。
- 对于精度要求不高的应用可以通过低精度浮点数来提高计算性能。例如,使用单精度浮点数代替双精度浮点数,或者半精度(half)或混合精度(mixed precision)代替单精度浮点数。
- 使用量化模型提升推理的性能。相对于浮点数,定点数的吞吐更高,因此在可接受的模型精度丢失情况下,采用量化模型可以带来可观的性能提升。并且量化模型还具有轻量,低功耗等优点。