从矩阵乘法代码看编译器的自动优化
在高性能计算中,矩阵乘法是一个非常基础且重要的操作。一个看似简单的实现,背后却可能蕴含着编译器大量的分析和优化工作。本文将以一个基础的矩阵乘法C语言代码为例,探讨编译器是如何通过SCEV (Scalar Evolution) 表达式来分析循环中的内存访问模式,并在此基础上执行循环展开 (Loop Unrolling) 等优化,从而大幅提升程序性能。
1. 矩阵乘法C代码·
首先,我们来看一个标准的 N × N 矩阵乘法实现 C = A * B。为了简化,我们使用一维数组来模拟二维矩阵,其中元素 M[i][j] 通过 M[i * N + j] 来访问。
1 | void MatrixMul(unsigned int N, int *C, int *A, int *B) { |
这段代码通过三层嵌套循环,精确地实现了矩阵乘法的数学定义。虽然功能正确,但其性能在不经过优化的情况下通常不理想。接下来,我们将探讨编译器如何“读懂”并优化这段代码。
2. 编译器如何理解循环:SCEV表达式分析·
为了进行有效的优化,编译器首先需要理解循环中变量和内存地址是如何变化的。LLVM等现代编译器使用一种称为SCEV (Scalar Evolution) 的技术来分析循环中的标量值的演变规律。
SCEV基本概念·
SCEV将循环中变量的变化规律表示为一个“Add Recurrence” (AddRec) 表达式,通常记为 {start, +, step}。
start: 循环第一次迭代时变量的初始值。+: 表示这是一个加法(或减法)递推。step: 每次循环迭代时变量增加(或减少)的步长。
例如,一个从 0 到 N-1 的循环变量 i,其SCEV表达式就是 {0, +, 1}。
SCEV的基本运算规则·
SCEV表达式支持基本的算术运算,例如:
- 加法:
{S1, +, T1} + {S2, +, T2} = {S1 + S2, +, T1 + T2} - 与常量相加:
C + {S, +, T} = {C + S, +, T} - 与常量相乘:
C * {S, +, T} = {C * S, +, C * T}
分析 MatrixMul 代码中的SCEV传播·
编译器会从外到内,逐层分析每个循环,并将上一层循环的变量在本层循环中视为不变量(Constant)。
1 | void MatrixMul(unsigned int N, int *C, int *A, int *B) { |
通过SCEV分析,编译器得出结论:在最内层的核心计算循环中,内存访问模式是极其规律的。
- 对
A的访问是步长为 1 的连续访问 (stride=1)。 - 对
B的访问是步长为N的等距访问 (stride=N)。 - 对
C的访问地址是不变的。
3. 循环展开 (Loop Unrolling)·
基本概念·
循环展开是一种通过复制循环体来减少循环总迭代次数的技术。这样做可以带来几个核心好处:
- 减少循环开销:每次迭代都需要执行循环变量的递增和条件判断。展开后,这些指令的执行频率降低了。
- 增加指令级并行 (ILP):现代CPU可以同时执行多条指令(超标量、流水线)。循环展开将多个独立的计算操作暴露给CPU,使其能够更好地并行处理,隐藏延迟。
- 为其他优化创造机会:展开后的代码更有利于指令调度和向量化(SIMD)等优化。
MatrixMul 内层循环的展开示例·
编译器会尝试展开最内层的 k 循环。假设展开因子 (unroll-count) 为 4:
原始循环:
1 | for (k = 0; k < N; k++) { |
展开后的伪代码:
1 | // 处理循环次数不是4的倍数的剩余部分 |
在展开后的主循环中,一次迭代就完成了四次原始迭代的工作。这不仅将循环判断和跳转指令的数量减少到原来的1/4,还使得CPU可以并行加载 A 和 B 的数据,并同时执行多个乘加运算,从而极大地提高了计算效率。
4. 汇编代码分析·
以下是 MatrixMul 函数在开启优化后由 GCC 编译生成的 RISC-V 完整汇编代码。我们将分段剖析它。
1 | MatrixMul: |
三层循环初始化及步长变化 : SCEV 的应用·
SCEV 的核心作用是把循环中复杂的变址计算(如 i * N)转变为简单的基地址加步长的递推关系。这一点在外两层循环的指针维护做的比较多。
外层 i 循环: SCEV 分析得出,在 i 循环中,A 和 C 矩阵的行首地址 &A[i*N] 和 &C[i*N] 都是以 N * sizeof(int) 为步长递增的,其 SCEV 表达式为 {base, +, N*4}。
1 | slli a6,a0,2 # a6 = N * 4 (预计算步长) |
-
t3和t5寄存器分别保存了A和C当前处理行的基地址。 -
在每次
i循环结束时(标签.L46处),代码执行add t3, t3, a6和add t5, t5, a6。这里的a6就是预先计算好的步长N*4。 -
这完美地印证了 SCEV 的分析:编译器放弃了在每次循环中重新计算
i * N的笨办法,而是通过在前一次迭代的基地址上直接增加一个固定的步长,来获得当前行的地址。
次内层 j 循环: 同样的原理也应用在 j 循环中。对于 C[i*N+j],其地址在 j 循环内的 SCEV 是 {&C[i*N], +, 4}。对于 B 矩阵 &B[k*N+j] ,其地址偏移也以会随 j 的递增 4 字节为单位递增。
1 | # j-loop 的结尾,准备下一次迭代 |
最内层循环 (k-loop)·
余数处理·
这部分是优化的核心。GCC 决定将最内层的 k-loop 展开 8 次。为了处理 N 不是 8 的倍数的情况,它采用了一种非常高效的余数处理策略。
1 | sw zero,0(a1) # C[i*N+j] = 0 |
GCC 没有使用一个单独的循环来处理余数,而是生成了一段精巧的 “fall-through” 代码。如果 N % 8 = 3,程序会跳转到 .L33,执行一次乘加,然后顺序执行 .L32 和 .L31 的代码,恰好完成3次迭代。这种方式避免了额外的循环判断开销。
展开的主体循环·
如果 N 大于 7,在处理完余数后,代码会进入 .L4 标签后的主循环。这里我们可以清晰地看到循环体被复制了8次。
1 | .L4: # 主循环体,一次处理8个元素 |
5. 反思·
本文在四个月前就想要写了,当时想写的内容远比现在呈现的多得多,为此付出的前期工作也远比文中提到的要多。
记得起初,我为这个主题编写了专门的测试 driver 程序,在玄铁的 Linux K230 开发板上,详细对比了 GCC 和 Clang 编译 matmul 后的性能与汇编代码差距。当时记录下了一些中间文件和心得,原本打算将这些内容完善成一个系列文章,完整地介绍从优化跑分到优化分析的一系列过程。
所以说,有什么想干的事,真的要尽早地去做,不要拖延,“72小时黄金时间”是对的。
在这里,我还是粗略地记录一下当时的一些笔记吧,也许后面会把这个“坑”填上。
- 测试程序的输入矩阵不能过大。否则,程序的性能瓶颈将由访存效率决定,代码本身的优化将难以体现出优势。
- 矩阵大小为4或8的倍数时,效率通常会更好。从多组控制矩阵大小的数据来看,存在这个趋势,猜测是因为 Cache Line 的原因。
- 对于一个调度已经很优秀的基本块,减少其中一两条指令,对其性能优化影响不大。
- GCC 和 Clang 都没有将代码优化成如下“累加器”形式:
1 | int acc = 0; |
这个优化的主要障碍是编译器的别名分析 (Alias Analysis) 不成功。编译器无法确保写入地址 C 和读取地址 A、B 指向的内存区域一定不重叠。如果将 A、B 矩阵和 C 矩阵的类型分开(例如 A、B 使用 short 类型,C 使用 int 类型),那么 TBAA (Type-Based Alias Analysis) 将可以分析出它们一定不是别名,从而让编译器放心地进行此项优化。