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)代替单精度浮点数。
- 使用量化模型提升推理的性能。相对于浮点数, 定点数的吞吐更高,因此在可接受的模型精度丢失情况下,采用量化模型可以带来可观的性能提升。并且量化模型还具有轻量,低功耗等优点。
9.1.5. 特殊计算单元
- Tensor Core
- Tensor Core是一种针对矩阵计算优化的硬件加速器。使用 Tensor Core 可以大幅提高矩阵乘法的计算性能,但需要注意数据类型和格式的兼容性。在QY系列显卡中,我们提供了对mma的支持,例如16x8x16 IMMA指令。
9.2. 访存优化
MUSA可用的存储器的速度由快到慢是, 通用寄存器 > 常量内存 > 共享内存 > L1/L2/LLC > 全局内存. 优化GPU访存的核心是让操作尽量发生在速度快的存储器中. 注意, 共享内存和全局内存的访问都存在访存合并行为, 即连续的线程访问连续的地址则请求可以合并, 以充分利用存储器的带宽. 所有线程访问相同的地址会触发广播, 即实际只有一条真实的访存请求被下发给存储器, 其请求的数据会被分发给不同的线程, 降低存储器的压力.
- reduce变量尽量在寄存器上reduce完再写回全局内存
- block内不同线程reduce数据通过共享内存进行
- 在共享内存中做转置保证不同线程读写全局内存可以合并, 一般情况下共享内存的速度远快于全局内存
- 访存和计算是不同的硬件单元, 可以通过double buffer等手段在线程层面让访存和计算并行, 也可以通过提高occupancy增加线程间的计算和访存并行
- 当线程间对全局内存的访问无法合并时应尽量提升单个线程访问全局内存的量, 以提升cacheline利用率
9.3. inline MUSA IR
> 敬请期待后续版本更新
9.4. 性能调优方法及工具
9.4.1. mSight
> 敬请期待后续版本更新
9.4.2. 自定义性能统计与分析
9.4.2.1. Kernel Level Roofline
首先Kernel Level Roofline Model主要描述了某个kernel在一个计算平台的限制下,到底能达到多快的浮点计算速度。更具体的来说,它解决的,是“计算量为A且访存量为B的kernel在算力为C且带宽为D的计算平台所能达到的理论性能上限E是多少”。
-
计算平台指标
- 算力$\pi$:指的是一个计算平台倾尽全力每秒钟所能完成的浮点运算数。单位是 $FLOPS$ or $FLOP/s$。
- 带宽$\beta$:指的是一个计算平台倾尽全力每秒所能完成的内存交换量。单位是$Byte/s$。
- 计算强度上限$I_{max}$:它描述的是在这个计算平台上,单位内存交换最多用来进行多少次计算。单位是$FLOPS/Bytes$:
$$I_{max} = {\pi \over \beta }$$
-
Kernel指标
- 计算量:指的是执行某个kernel所发生的浮点运算个数。单位是 $FLOP$或者$FLOPs$。
- 访存量:指的是输入单个样本,执行kernel所发生的内存交换总量。
- 计算强度: 由计算量除以访存量就可以得到kernel的计算强度,它表示此kernel在计算过程中,每Byte内存交换到底用于进行多少次浮点运算。单位是$FLOPs/Byte$。
-
Roofline Model
Roofline Model如图1所示,我们可以把Roofline Model分为两个区域:
- 计算瓶颈区:当模型的计算强度超过平台的理论最大计算强度的时候,算力最多只能达到计算平台的算力;反之如果计算密度较大,程序性能受硬件最大计算峰值限制,称为计算密集型程序,即图中绿色区域。此时性能上界=硬件算力,表现为图中的横线。此时计算速度不受计算密度影响,但计算密度越大,所需内存带宽就越少。
- 带宽瓶颈区表示:当模型的计算强度小于平台的理论最大计算强度的时候,算力是受平台带宽限制的(假设计算强度固定)。当程序的计算密度较小时,程序访存多而计算少,性能受内存带宽限制, 称为访存密集型程序,即图中红色色区域。在此区域的程序性能上界=计算密度×内存带宽,表现为图中的斜线,其中斜率为内存带宽的大小。计算密度越大,程序所能达到的速度上界越高,但使用的内存带宽始终为最大值。

图1
-
基于Roofline的Kernel性能分析
在真实世界中,核函数都必须依赖于具体的计算平台才能展现自己的实力。这样他们和计算平台的"默契程度"才能决定核函数的实际表现,而Kernel Level Roofline恰好反映了Kernel和计算平台之间的关系。根据计算平台的理论性能、kernel 的计算强度,和实际运行中 kernel 的计算效率就可以画出
kernel level roofline model。如图2, 其中横轴代表 kernel 的计算强度,而纵轴就是该 kernel 所能达到的最大计算性能。我们可以把图像分为以下几个区域:- 粉色线以上和蓝色线以左的区域是程序无论如何都无法达到的性能,因为它意味着超过了计算机的峰值计算性能/访存带宽。
- 淡蓝色区域(计算密度小于$I_{max}$点)是性能较好的访存密集型程序,这部分程序的访存带宽利用率较高,评价访存密集型程序的指标主要选用访存带宽。
- 淡粉色区域(计算密度大于$I_{max}$点)是性能较好的计算密集型程序,有较好的数据重用率与数据局部性,这部分程序的浮点性能较高,评价计算密集型程序的指标主要选用浮点性能。
- 红色虚线部分,带宽与浮点性能都远低于峰值性能的kernels,如果程序性能处于这个部分,需要考虑优化算法提高性能,达到粉色或者蓝色 区域。
至于那些贴近于roofline的kernel较好的利用了计算资源(绿点),而那些远离roofline的kernel(红点)则是我们重点需要去优化、提高计算资源利用率的。这里需要注意,对比FLOP/s很低的kernel(例如第一个绿点),红点虽然看起来FLOP/s更高,但是比绿点更有优化性价比。红点离roofline还有较远距离,可通过不断优化访存函数、计算函数来提高访存带宽/浮点计算性能利用率(可根据坐落的区域判断)。除此之外,如果想要提高FLOP/s很低但是又比较接近roofline的kernel,可以通过改进计算方法/减少数据传输时间来提高计算密度(例如提高空间局部性、提高cache命中率、改进数据结构、数据类型)。

图2
上述的方案中,我们默认带宽和访存量都是针对的GPU device memory,但其实kernel的性能是受以下四种memory限制的:
- Registers
- L1,L2,cache
- HBM (GPU device memory)
- DDR (host memory)

图3
不同的memory具有不同的带宽,而同一个kernel在不同的memory level下有不同的访存量。图3就展示了不同memory level下的roofline情况帮助我们分析kernel在不同存储结构下的性能表现。首先图中画了三条不同memory level下的roofline。同一个kernel是用同一种颜色标识的,从图中可以发现同一个kernel不管是在哪个memory level下具有相同的计算性能(因为在同一个计算平台下), 但由于不同的访存量所以具有不同的计算密度。同一个kernel在不同memory level下之间的距离可以衡量cache的利用率,如果同一个kernel在不同level下之间距离很短,说明cache利用率不高,如果很长,说明cache利用率高。除此之外,也可以单独分析每个kernel在某个存储结构下的性能瓶颈,从而提高kernel性能。

图4
-
基于指令级别Ceilings的kernel Roofline分析
Roofline model展示了性能的上界,那如果性能表现远低于roofline的话,我们应该按什么顺序优化我们的程序呢?除了上述方案中对kernel程序本身做出优化,这里也列出了一些指令级别的performance ceilings,如果没有做到某个ceiling相关的优化,是不可能达到该ceiling的上界的。这里将ceiling分为计算瓶颈ceiling和带宽瓶颈ceiling。
- 计算瓶颈的优化:
- 优化ILP( instruction level parallelism)和应用SIMD:
- 指令级并行( ILP, Instruction Level Parallelism)是指利用流水级并行和多指令发射等方式提高程序执行的并行度;
- 数据级并行(DLP, Data Level Parallelism)是指处理器能够同时处理多条数据的并行方式,即SIMD。
- floating-point balanced(平衡浮点运算组合):要达到浮点运算性能表现,需要乘法和加法的次数尽可能一样,因为许多的计算平台有专门的multiply-add指令。
- 带宽瓶颈优化
- Restructure loops for unit stride accesses(重构单位步幅访问循环)
- Ensure memory affinity(确保内存亲和性):这个 优化分配数据和分配给该数据的线程到相同的内存处理器对,以便处理器很少需要访问连接到其他芯片的内存。
- Use software prefetching(使用软件预取):在某些计算机上, 软件预取提供比硬件单独预取带来更多的带宽。
图5(a)展示了计算瓶颈ceilings,说明了在imbalance floating-point下, 算力最多达到8.8GFlops/s, 而在没有优化ILP和SIMD下,算力最多只能达到2.2GFlops/s。(b)展示了带宽瓶颈ceilings,道理同上。而(C)是两种ceilings的混合,他告诉我们在不同的计算强度下,该如何选择优化方式。首先对某个kernel计算其计算强度,再对该点做垂线,与该线能交汇的ceilings则是可以优化的方向。比如kernel2只和计算瓶颈ceilings相交,所以它只能做计算瓶颈的指令优化。而kernel1,既可做计算瓶颈优化,也可做么带宽瓶颈优化。

图5
9.4.3. mcc性能调优支持
mcc提供了包括优化选项、函数级别的属性、代码级别的注解等方式,为MUSA代码性能调优提供支持。
9.4.3.1. 优化选项
优化选项一般由-mllvm开头,每个优化选项开关前都需要加一个-mllvm作为前置修饰。
`-mtgpu-maxregcnt=
`
为本次编译设置最大temporary register使用数量限制。
使用示例:
mcc axpy.mu -lmusart -L/usr/local/musa/lib -mllvm -mtgpu-maxregcnt=256
9.4.3.2. 函数属性
mtgpu-num-usreg
该属性修饰kernel函数,用于限制该kernel使用的temporary register数量。“0”表示对temporary register数量不进行限制。
使用示例:
__global__ attribute((mtgpu-num-usreg(256))) void axpy(float *a, float *b, float c) {
a[threadIdx.x] = b[threadIdx.x] * c;
}
mtgpu-unroll_threashold
该属性修饰kernel函数,用于限制kernel中的循环展开阈值。使用方式与mtgpu-num-usreg相同。