
GEMM优化原理
整理 GPU GEMM 优化的一般分块思路:global memory 到 shared memory、shared memory 到 register、register 分块和 prefetch
针对 NVIDIA GPU 的通用矩阵乘法(GEMM)优化已有非常成熟的套路,此处进行梳理,介绍 GEMM 中的数据分块和如何在多级存储进行数据搬运,核心思想是怎么样让数据放在更近的存储上,从而将访存延迟掩盖在计算延迟中,减少存储墙的影响,这也是 HPC 优化的核心思想。
问题定义
NVIDIA 的存储资源分为 global memory、shared memory 和 register 三种,三者访问速度依次变快,容量依次减小。如果反复从 global memory 中读取,数据复用太差,就会导致大量时间浪费在等待数据搬运,计算单元长期空转,也就是所谓的存储墙问题。因此需要对矩阵分块将子矩阵的数据搬运到 shared memory 中,再对子矩阵分块将小矩阵的数据搬运到 register 中,同时利用 double buffer(ping-pong buffer)让访存和计算同步进行,通过这些方式将访存延迟隐藏在计算延迟中,这就是大致的优化思路。关于分块的具体的分析后续展开,此处先定义相关变量。
GEMM(General Matrix Multiplication)一般写作:
其中 分别为 的矩阵。将 三个矩阵分别划分为多个 的子矩阵,三个矩阵分别形成 的子矩阵网格。同理再将 的子矩阵划分为多个 的小矩阵,形成 的小矩阵网格。

有如下关系成立(此处讨论可以对齐整除的情况):
此外,优化的边界限定为一次 GEMM 计算,主要的优化对象是 MAC 计算所依赖的读取环节,写入延迟在这里并不是瓶颈,因此不作讨论。
从 global memory 到 shared memory 再到 register
基于 global memory 计算
naive CUDA 的常规写法是,启动 个线程,一个线程负责 的一个元素。每个线程读取 的一行和 的一列。这样计算一个元素就需要从 global memory 中读取 个元素,算完整个矩阵就需要对 global memory 进行 次读取。这样的问题是数据复用太差,同一行 和同一列 会被许多线程反复从 global memory 读取,而 global memory 的访存速度相对于 MAC 计算速度是极慢的,因此就出现了访存瓶颈。
计算整个矩阵所需的读取操作:
基于 shared memory 计算
将矩阵分块为子矩阵后,启动 个 thread block,一个 thread block 内的线程数为 。一个 thread block 负责计算 的一个子矩阵( 个元素),本质上还是一个线程负责 的一个元素,区别在于这一个 block 内的 thread 可以复用 shared memory 中的数据。得到 中的每个子矩阵需要将 个 子矩阵相乘的结果累加,即经过 次迭代得到,这个迭代在后文统称为大迭代。

对于每一个 子矩阵相乘,由于子矩阵读取到 shared memory 中可以复用,而不是像 global memory 中那样每次都需要重复读取,因此计算一个子矩阵只需要从 global memory 中将 子矩阵中全部元素读取到 shared memory,即需要从 global memory 中读取 次,然后再从 shared memory 中读取 次。算完整个矩阵( 次迭代)总共需要从 global memory 中读取 次,再从 shared memory 中读取 次。
计算整个矩阵所需的读取操作:
基于 register 计算
和上面同理,做完 block 级的 tile 分块之后,shared memory 也存在大量重复读取,于是可以进一步将需要复用的数据搬到 register 上。将子矩阵分块为小矩阵,每个线程负责一个小矩阵( 个元素)而不是一个元素的计算,一个 thread block 仍然负责一个子矩阵( 个元素),因此一个 block 内线程数为 ,总共启动 个 thread block。得到一个小矩阵 ,需要完成一个 矩阵和一个 矩阵相乘的计算;具体来说会依次完成 次 列矩阵和 行矩阵的相乘,每次会得到一个小矩阵 的中间结果, 次中间结果累加迭代即得到要算的小矩阵,这个迭代在后文统称为小迭代。另外,由于每个线程负责的元素变为了 个,因此会出现一个取舍, 取大,单线程数据复用更好,但寄存器占用上升,可能降低 occupancy。

对于每一个小矩阵的计算,和上面同理,只需要从 shared memory 中读取 次,然后再从 register 中读取 次。算完整个子矩阵的一次迭代总共需要从 shared memory 中读取,再从register中读取次。
计算整个矩阵所需的读取操作:
寄存器重排
对一个 thread 计算小矩阵的某一次迭代而言,拥有 个 矩阵的值、 个 矩阵的值以及 个 矩阵的值,完成本次迭代需要 条 FFMA(Floating-point Fused Multiply-Add,浮点融合乘加指令)指令。在每一条 FFMA 执行过程中,如果直接进行 naive 地执行,会存在大量的 register bank conflict,因此需要对寄存器进行重排。

bank 机制是为了并行访问而把一块存储切分成多个小存储的设计,存储的物理硬件是一整块单口 SRAM,bank 设计的目的是用可接受的成本提供高并行的访问带宽。多个线程可以并行访问不同的 bank,但访问同一个 bank 就可能发生冲突而使得访存串行化。对 shared memory bank 而言是把地址映射到 bank 上,对 register bank 而言是把源寄存器映射到 bank 上。存储和 bank 的映射关系在不同的硬件架构上可能也不同,以 32bit-word、32 个 bank 的 shared memory 为例,一个典型的映射是 bank_id = (address / 4) % 32。在 GEMM 里,这个问题常出现在按列读 tile、做转置、或者 stride 刚好是 32 的倍数时。常见处理方式是 padding:__shared__ float tile[32][33];,多出来的一列不存有效数据,而是为了让下一行的起始地址错开 bank 映射。
回到这里的计算,如果一条指令的源寄存器有两个以上来自同一 bank,就会产生 conflict 导致指令重发射而浪费一个 cycle,这对于计算密集型的算子尤其重要。因此需要对寄存器进行重排。CUDA C++ 通常不能直接控制变量最终分配到哪个物理寄存器。只有写 PTX/SASS 或做很底层的 kernel 排布时,才会专门考虑寄存器编号、累加器布局和 FFMA 顺序。普通 GEMM 优化先看 global/shared/register 分块是否合理;继续逼近峰值算力时,bank conflict、指令顺序和汇编排布才会变成主要问题。
prefetch 和双缓冲
按照上述的优化,GEMM 计算会不断进行下面的流程
- 从 global memory 读取下一组 tile 到 shared memory。
- 从 shared memory 读取当前小片段到 register。
- 用 register 中的数据执行 FFMA。
如果三者串行执行,计算单元显然会频繁等待数据,因此需要做数据预取,并行进行下一批数据的搬运和当前批数据的计算。具体方式是使用双缓冲(double buffer / ping-pong buffer),双缓冲的思想非常朴素,即准备两份存储轮流负责预取搬运和当前计算,这样就可以提前发起下一批数据搬运,用计算指令尽量掩盖数据搬运的延迟。
当然,prefetch 也存在代价,一方面是存储资源的翻倍,另一方面是资源占用变高之后 active block 数可能下降,因此 prefetch 需要和分块参数一起调参。好的参数组合能提高数据复用并掩盖 latency;差的参数组合可能因为 register 和 shared memory 占用过高,反而降低并发度。理想情况下,如果访存延迟可以完全隐藏在计算延迟中,在线程并行的情况下,当一个线程完成小矩阵的计算时,整个矩阵的计算也就完成了。
一个具体的例子
设矩阵大小为 ,分块参数为 ,其它衍生参数如下。
- block 数为
- 每个 block 负责 个 元素
- 每个 block 线程数为
- 每个线程负责 个输出元素
- 每个 block 在 方向有 次大迭代
- 每个 thread 在 方向有 次小迭代
- 每次小迭代会产生 条 FFMA 指令
- 对于子矩阵,每次大迭代需要将 的 个元素和 的 个元素从 global memory 搬运到 shared memory 中。
kernel实现此处不作详细讨论,不过概括来说,核心就是此处介绍的原理,用双层 buffer 把 global/shared/register 三段数据流串成流水线,沿着 global -> shared -> register -> compute 这条路径减少重复搬运、提高局部性,并用更多片上资源去换取更少的等待时间。
评论
评论正在加载…