第四章:循环展开——手动展开与编译器自动展开
循环展开,说白了就是让CPU少做判断、多干活。我刚开始做嵌入式优化时,总觉得这玩意儿是编译器该操心的事。直到有一次,一个视频解码的循环死活跑不满流水线,我才真正意识到——有些优化,编译器真不一定比你聪明。
4.1 为什么需要循环展开?
先看一个最普通的循环:
for (int i = 0; i < 1000; i++) {
a[i] = b[i] + c[i];
}
每次迭代,CPU都要做三件事:取指令、执行加法、判断i是否小于1000。你想想看,这个判断操作占了将近三分之一的指令开销。对于现代CPU来说,分支预测失败一次,代价就是十几个时钟周期。
展开之后是什么样?
for (int i = 0; i < 1000; 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];
}
循环次数从1000次降到了250次。判断开销直接砍掉75%。我在项目中遇到过类似场景,一个音频处理函数,展开后性能提升了将近40%。
4.2 手动展开 vs 编译器自动展开
很多编译器确实支持自动展开,比如GCC的-funroll-loops选项。但这里有个坑——编译器默认的展开策略往往偏保守。
为什么?因为编译器不知道你的数据量有多大。它只能根据静态分析做猜测。我个人的习惯是:关键路径上的循环,手动展开;非关键路径,交给编译器。
| 对比项 | 手动展开 | 编译器自动展开 |
|---|---|---|
| 控制粒度 | 精确到每一条指令 | 受限于编译器策略 |
| 代码可读性 | 变差 | 保持不变 |
| 可移植性 | 差(依赖架构) | 好 |
| 调试难度 | 高 | 低 |
举个例子,我曾经在ARM Cortex-A72上优化一个矩阵乘法。手动展开4倍后,性能比GCC的-O3 -funroll-loops还高出15%。原因很简单——编译器不知道我的矩阵大小是固定的128x128,它不敢做激进展开。
4.3 展开因子怎么选?
展开因子不是越大越好。我见过有人把循环展开成32倍,结果代码膨胀到L1指令缓存都装不下,性能反而下降了。
选择展开因子,主要看三个因素:
- 寄存器压力:展开后需要更多寄存器保存中间结果。ARM有16个通用寄存器,x86有16个(不算AVX)。展开因子超过寄存器数量,就会发生寄存器溢出,性能直接崩盘。
- 指令缓存大小:现代CPU的L1指令缓存通常是32KB。展开后的代码如果超过这个大小,就会频繁触发缓存缺失。
- 数据依赖链:如果循环体内部有数据依赖,展开后反而可能因为指令并行度不够而浪费。
我个人的经验公式:展开因子 = min(寄存器可用数, 指令缓存容量/循环体大小, 4)。对于大多数场景,2到4倍展开是最稳妥的选择。
核心原则:展开因子选到刚好让CPU的流水线饱和即可。多一分则溢,少一分则亏。
4.4 避免代码膨胀的实战技巧
代码膨胀是循环展开最大的副作用。我见过一个项目,因为过度展开,二进制体积从200KB膨胀到1.2MB,在Flash只有512KB的MCU上直接翻车。
怎么避免?
- 只展开热点循环:用profiler找出真正消耗CPU时间的循环,别在无关紧要的地方浪费时间。
- 使用宏或模板:用C语言的宏或者C++的模板来生成展开代码,既保持可读性,又方便调整展开因子。
- 条件编译:在调试版本中不展开,发布版本再展开。我习惯用
#ifdef PROFILE来控制。
#define UNROLL_FACTOR 4
#if UNROLL_FACTOR == 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];
}
#else
for (int i = 0; i < N; i++) {
a[i] = b[i] + c[i];
}
#endif
嗯,这里要注意:展开后的剩余元素要单独处理。比如N=1002,展开4倍后还剩2个元素,得加个尾循环。
__builtin_expect告诉编译器大概率不会执行,帮助它做分支预测优化。
4.5 知识体系总览
下面这张图,是我对循环展开策略的总结。你可以把它当作一个决策树来用:
这张图的核心逻辑很简单:先判断是不是热点,是的话手动展开,然后根据寄存器和缓存情况选因子。非热点就交给编译器,省心省力。
最后说一句:循环展开不是银弹。我见过有人为了炫技,把简单的循环展开成天书,结果维护成本飙升。记住,优化的目的是让程序跑得更快,不是让代码看起来更复杂。