4、循环变换技术:循环交换、循环分块(Tiling)、循环展开
好,咱们接着聊内存墙。前面几章我反复强调,访存延迟是性能杀手。那编译器能做什么?说白了,就是通过变换循环结构,让数据访问模式更「友好」。今天我把三种最常用的循环变换技术掰开揉碎了讲:循环交换、循环分块、循环展开。
这三种技术,我个人的经验是:循环分块是解决内存墙的核武器,循环交换是前置条件,循环展开是锦上添花。你想想看,如果数据在内存里东一榔头西一棒子,CPU再快也没用。
4.1 循环交换(Loop Interchange)
循环交换,就是把嵌套循环的内外层对调。为什么要这么做?因为C/C++是行优先存储的。你按列访问,缓存行就废了。
举个例子,矩阵乘法里最经典的坑:
// 糟糕的版本:按列访问
for (int j = 0; j < N; j++) // 外层是列
for (int i = 0; i < M; i++) // 内层是行
A[i][j] = 0;
这个写法,每次访问A[i][j]都会跳一行。缓存行里预取的数据全浪费了。我刚开始做优化时也犯过这错,跑一次矩阵初始化慢得离谱。
改成行优先:
// 优化的版本:按行访问
for (int i = 0; i < M; i++) // 外层是行
for (int j = 0; j < N; j++) // 内层是列
A[i][j] = 0;
就这么一换,性能能差出好几倍。嗯,这里要注意:循环交换的前提是循环之间没有依赖关系。如果有数据依赖,交换后结果就错了。
核心原则:让最内层循环的访存步长为1,充分利用空间局部性。
4.2 循环分块(Loop Tiling / Blocking)
循环分块,这才是重头戏。它的思想很简单:把大矩阵切成小块,让每一块都能塞进缓存里。
为什么需要分块?你看矩阵乘法C = A * B。如果三个矩阵都很大,A的一行和B的一列根本不可能同时待在缓存里。每次计算C[i][j]都要从内存重新加载,缓存命中率惨不忍睹。
分块后的代码长这样:
#define BLOCK_SIZE 64 // 根据缓存大小调整
for (int ii = 0; ii < N; ii += BLOCK_SIZE)
for (int jj = 0; jj < N; jj += BLOCK_SIZE)
for (int kk = 0; kk < N; kk += BLOCK_SIZE)
// 处理一个 BLOCK_SIZE x BLOCK_SIZE 的小块
for (int i = ii; i < ii + BLOCK_SIZE; i++)
for (int j = jj; j < jj + BLOCK_SIZE; j++)
for (int k = kk; k < kk + BLOCK_SIZE; k++)
C[i][j] += A[i][k] * B[k][j];
你看,原来三层循环变成了六层。但性能提升是质的飞跃。我在项目中遇到过一个大矩阵乘法,没分块时跑了几十秒,分块后降到两秒以内。当时团队里的人都惊了。
分块大小的选择:我建议先查一下L1数据缓存的大小。比如L1是32KB,每个double是8字节,那一个块最多能放4096个元素。再考虑三个矩阵同时占用,块大小取64左右比较安全。
为什么会这样?因为分块后,A的一个子块和B的一个子块可以同时驻留在缓存里。内层循环反复使用这些数据,不用频繁去内存搬数据。
4.3 循环展开(Loop Unrolling)
循环展开,就是减少循环控制的开销,同时给编译器更多指令级并行的机会。
最简单的展开:
// 原始循环
for (int i = 0; i < N; i++)
a[i] = b[i] + c[i];
// 展开4次
for (int i = 0; i < N; i += 4) {
a[i] = b[i] + c[i];
a[i+1] = b[i+1] + c[i+1];
a[i+2] = b[i+2] + c[i+2];
a[i+3] = b[i+3] + c[i+3];
}
展开后,循环次数减少了3/4。更重要的是,现代CPU可以同时发射多条无依赖的指令。这四个加法互不依赖,可以并行执行。
不过,展开因子不是越大越好。我曾经试过展开32次,结果寄存器不够用,反而导致寄存器溢出,性能暴跌。
避坑指南:我曾经在ARM平台上把循环展开到16倍,结果性能还不如不展开。后来发现是寄存器压力太大,编译器不得不把变量 spill 到栈上。展开因子一般取2到8比较稳妥。
4.4 三种技术的协同使用
这三种技术不是孤立的。实际优化时,我通常这样组合:
- 先做循环交换:确保最内层循环的访存是连续的。
- 再做循环分块:把大问题切成缓存友好的小块。
- 最后做循环展开:在分块后的内层循环上展开,榨干最后一点性能。
举个例子,优化后的矩阵乘法:
#define BLOCK 64
#define UNROLL 4
for (int ii = 0; ii < N; ii += BLOCK)
for (int kk = 0; kk < N; kk += BLOCK)
for (int jj = 0; jj < N; jj += BLOCK)
// 内层循环展开4次
for (int i = ii; i < ii + BLOCK; i++)
for (int k = kk; k < kk + BLOCK; k++) {
int j = jj;
for (; j < jj + BLOCK - UNROLL + 1; j += UNROLL) {
C[i][j] += A[i][k] * B[k][j];
C[i][j+1] += A[i][k] * B[k][j+1];
C[i][j+2] += A[i][k] * B[k][j+2];
C[i][j+3] += A[i][k] * B[k][j+3];
}
// 处理剩余元素
for (; j < jj + BLOCK; j++)
C[i][j] += A[i][k] * B[k][j];
}
这个版本,分块保证了缓存命中,展开给了CPU指令级并行的空间。嗯,这才是编译器优化的正确姿势。
4.5 知识体系图
下面这张图总结了三种循环变换技术的关系和适用场景:
总结一下:循环变换是编译器对抗内存墙的第一道防线。循环交换解决访存方向问题,循环分块解决缓存容量问题,循环展开解决指令发射问题。三者配合,才能让CPU真正跑满。
想要更多资料,联系可可