循环优化基础:循环展开、循环分块、循环交换、循环融合与分裂

说到循环优化,我脑子里第一个蹦出来的画面是几年前调一个图像处理算子。那时候性能死活上不去,CPU流水线一直在空转,缓存命中率惨不忍睹。后来我把循环结构重新梳理了一遍,性能直接翻了三倍。嗯,今天咱们就来聊聊循环优化的几个基本功。

循环是算子的灵魂。你想想看,不管是矩阵乘法、卷积还是各种变换,底层全是循环在干活。但循环怎么写,性能差距能到十倍以上。我见过太多人把循环写得规规矩矩,结果编译器看了直摇头。

核心观点:循环优化的本质是让数据流动更顺畅,让计算单元更忙碌。说白了,就是跟内存层次结构和指令流水线搞好关系。

循环展开(Loop Unrolling)

循环展开是最直观的优化手段。它的思路很简单:减少循环控制开销,给编译器更多指令调度的空间。

举个例子,一个简单的向量加法:

// 原始循环
for (int i = 0; i < N; i++) {
    C[i] = A[i] + B[i];
}

// 展开4次
for (int i = 0; i < N; i += 4) {
    C[i]   = A[i]   + B[i];
    C[i+1] = A[i+1] + B[i+1];
    C[i+2] = A[i+2] + B[i+2];
    C[i+3] = A[i+3] + B[i+3];
}
// 处理剩余元素
for (int i = N - N % 4; i < N; i++) {
    C[i] = A[i] + B[i];
}

展开之后,循环次数从N次降到了N/4次。每次循环少了一次条件判断和一次跳转。别小看这个,在循环次数上亿的场景下,这个开销相当可观。

我的经验:展开因子不是越大越好。我曾在某款ARM芯片上试过展开32次,结果寄存器溢出,性能反而下降了。一般展开2到8次比较稳妥,具体要看目标架构的寄存器数量。

展开还有一个隐藏好处:它让编译器能更好地做指令重排。比如上面的代码,编译器可以把四个加法指令交错排列,减少流水线停顿。我曾经在调试一个DSP算子时,展开后性能提升了40%,就是因为流水线利用率上去了。

循环分块(Loop Tiling)

循环分块,也叫循环分片,是我个人认为最实用的优化技术之一。它的核心思想是:把大块数据切成小块,让每一块都能塞进缓存。

来看矩阵乘法的经典例子:

// 原始三重循环
for (int i = 0; i < N; i++)
    for (int j = 0; j < N; j++)
        for (int k = 0; k < N; k++)
            C[i][j] += A[i][k] * B[k][j];

// 分块后的版本
for (int i = 0; i < N; i += BI)
    for (int j = 0; j < N; j += BJ)
        for (int k = 0; k < N; k += BK)
            for (int ii = i; ii < i + BI; ii++)
                for (int jj = j; jj < j + BJ; jj++)
                    for (int kk = k; kk < k + BK; kk++)
                        C[ii][jj] += A[ii][kk] * B[kk][jj];

为什么分块有效?原始版本中,B矩阵是按列访问的,每次访问都会触发缓存缺失。分块之后,A和B的小块都能留在缓存里,反复使用。我在项目中遇到过,一个256x256的矩阵乘法,分块后性能提升了5倍。

避坑指南:我曾经把分块大小设成跟缓存一样大,结果性能反而更差了。为什么?因为缓存还要存其他数据,比如指令、栈变量。分块大小一般取缓存大小的1/4到1/2比较合适。

循环交换(Loop Interchange)

循环交换,说白了就是调整循环的嵌套顺序。目的是让内存访问模式更连续,充分利用空间局部性。

看这个例子:

// 按列访问,缓存不友好
for (int j = 0; j < N; j++)
    for (int i = 0; i < N; i++)
        sum += A[i][j];

// 交换后,按行访问,缓存友好
for (int i = 0; i < N; i++)
    for (int j = 0; j < N; j++)
        sum += A[i][j];

C/C++中二维数组是行优先存储的。第一个版本按列访问,每次跳一行,缓存行里的其他数据全浪费了。第二个版本按行访问,连续读取,缓存命中率极高。

我记得有一次优化一个图像滤波算子,原始代码是列优先遍历,性能惨不忍睹。交换循环后,同样的算法,速度提升了8倍。你想想看,这还只是改了几行代码的顺序。

循环融合与分裂(Loop Fusion & Fission)

这两个操作是相反的,但目的都是为了优化数据重用和指令流水线。

循环融合:把多个独立的循环合并成一个。好处是减少循环开销,增加数据重用机会。

// 融合前
for (int i = 0; i < N; i++) A[i] = B[i] * 2;
for (int i = 0; i < N; i++) C[i] = A[i] + D[i];

// 融合后
for (int i = 0; i < N; i++) {
    A[i] = B[i] * 2;
    C[i] = A[i] + D[i];
}

融合后,A[i]刚算完就被用上了,还在寄存器里,不用写回内存再读回来。我在做神经网络算子时,经常把激活函数和后面的逐元素操作融合,效果立竿见影。

循环分裂:把一个复杂的循环拆成多个简单循环。有时候是为了让每个循环都能向量化,有时候是为了减少寄存器压力。

// 分裂前
for (int i = 0; i < N; i++) {
    A[i] = B[i] * C[i];
    D[i] = E[i] + F[i];
    G[i] = sqrt(A[i] * D[i]);
}

// 分裂后
for (int i = 0; i < N; i++) A[i] = B[i] * C[i];
for (int i = 0; i < N; i++) D[i] = E[i] + F[i];
for (int i = 0; i < N; i++) G[i] = sqrt(A[i] * D[i]);

我的建议:分裂后的每个循环要足够简单,让编译器能自动向量化。我曾经把一个包含条件分支的循环拆开,结果两个子循环都被向量化了,性能提升了2倍。

知识体系总览

下面这张图总结了循环优化的核心技术和它们之间的关系:

循环优化知识体系 循环优化 循环展开 循环分块 循环交换 循环融合与分裂 减少循环开销 提升缓存命中率 提高指令并行度 四大技术相互配合,共同提升算子性能

实际应用中的选择策略

这么多优化技术,到底该用哪个?我一般按这个思路来:

场景 推荐技术 原因
循环次数少,循环体小 循环展开 减少循环控制开销占比
大数据集,缓存放不下 循环分块 提升数据局部性
内存访问模式不连续 循环交换 让访问变成顺序的
多个小循环访问相同数据 循环融合 增加数据重用
循环体太复杂,无法向量化 循环分裂 让子循环变得简单可向量化

重要提醒:这些优化不是孤立的。实际项目中,我经常把循环分块和循环交换一起用,或者先做融合再做展开。关键是要理解你的数据流和硬件特性,而不是机械地套用模板。

最后说一句,循环优化没有银弹。我见过有人花了一周时间做循环展开,结果性能只提升了5%。也见过有人只调了一下循环顺序,性能翻倍。嗯,这就是为什么我总说:先分析,再优化,最后验证。别上来就动手改代码。

想要更多资料,联系可可