4. 循环优化(下):循环剥离、循环向量化、循环并行化的适用场景
上一讲我们聊了循环展开和循环合并,这次我把剩下的三个重头戏讲完——循环剥离、循环向量化、循环并行化。这三个技术,说白了就是让循环跑得更快、更聪明。我在项目里见过太多人一股脑全用上,结果性能反而下降。所以这一讲,咱们重点聊「什么时候该用,什么时候别用」。
4.1 循环剥离(Loop Peeling)
循环剥离,就是把循环的前几次或后几次迭代单独拿出来处理。为什么要这么做?
我举个例子。你有一个数组,要对每个元素做某种变换,但前两个元素需要特殊处理——比如它们是边界条件。如果你在循环体里加 if 判断,每次迭代都要多一次分支预测。分支预测失败的成本,在嵌入式里可不低。
// 不剥离:每次迭代都要判断
for (int i = 0; i < N; i++) {
if (i < 2) {
// 特殊处理
} else {
// 正常处理
}
}
// 剥离后:前两次单独处理
// 处理前两个元素
special_handle(&data[0]);
special_handle(&data[1]);
// 主循环无分支
for (int i = 2; i < N; i++) {
normal_handle(&data[i]);
}
你看,剥离之后主循环里少了一个 if。别小看这个 if,在循环次数很大的时候,分支预测失败的惩罚会累积。我曾经在一个电机控制项目里,用剥离把循环体里的条件判断去掉,整体执行时间降了 12%。
- 循环的前几次或后几次迭代有特殊边界处理
- 循环体内部有与循环变量相关的条件分支
- 循环次数已知且较大(至少几十次以上)
- 目标处理器分支预测能力弱(比如某些 Cortex-M 系列)
4.2 循环向量化(Loop Vectorization)
向量化,说白了就是让 CPU 一条指令处理多个数据。现代处理器大多有 SIMD(单指令多数据)指令集,比如 ARM 的 NEON、x86 的 SSE/AVX。循环向量化就是让编译器自动把你的循环转换成 SIMD 指令。
但这里有个坑:编译器不是万能的。它需要循环满足一些条件才能自动向量化。
| 条件 | 说明 | 常见反例 |
|---|---|---|
| 连续内存访问 | 数据在内存中是连续排列的 | 链表遍历、stride 不为 1 的访问 |
| 无数据依赖 | 每次迭代不依赖前一次的结果 | 累加求和(有依赖) |
| 循环次数已知 | 编译器能在编译时或运行时确定次数 | while 循环、break 提前退出 |
| 无函数调用 | 循环体内不能有非内联函数调用 | 调用 printf、外部库函数 |
我建议你养成一个习惯:写完循环后,打开编译器的向量化报告。GCC 用 -fopt-info-vec,Clang 用 -Rpass=loop-vectorize。看看编译器为什么没向量化,然后针对性修改。
// 可向量化:连续访问,无依赖
for (int i = 0; i < N; i++) {
c[i] = a[i] + b[i];
}
// 不可向量化:有数据依赖
for (int i = 1; i < N; i++) {
a[i] = a[i-1] + b[i]; // 依赖前一次结果
}
// 可向量化(改写后):消除依赖
for (int i = 0; i < N; i++) {
temp[i] = a[i] + b[i];
}
// 再用另一个循环处理依赖
for (int i = 1; i < N; i++) {
a[i] = temp[i-1] + b[i];
}
4.3 循环并行化(Loop Parallelization)
并行化,就是把循环的多次迭代分配到多个核心上同时执行。在嵌入式领域,多核处理器越来越常见,比如 Cortex-A 系列、RISC-V 多核方案。
但并行化有个前提:迭代之间必须没有数据依赖。否则就会出现竞态条件。
// 可并行化:每次迭代独立
#pragma omp parallel for
for (int i = 0; i < N; i++) {
result[i] = compute(input[i]);
}
// 不可并行化:有依赖
for (int i = 1; i < N; i++) {
sum += data[i]; // 所有迭代共享 sum
}
你想想看,并行化最大的开销是什么?是线程创建和同步。如果循环体本身执行时间很短,比如只有几个时钟周期,那并行化的开销可能比串行执行还大。我见过有人把 10 次迭代的循环用 OpenMP 并行化,结果比串行慢了 5 倍——线程创建的开销远大于计算本身。
- 循环次数大(至少几百次以上)
- 每次迭代的计算量较大(至少几十微秒)
- 迭代之间无数据依赖
- 目标平台有多个核心且核心间通信开销低
- 实时性要求允许一定的调度延迟
4.4 三种技术的选择策略
这三种技术不是互斥的,但也不是叠加越多越好。我一般按这个顺序判断:
- 先看能不能向量化——向量化收益最高,开销最小,编译器帮你做大部分工作
- 再看需不需要剥离——如果循环体有边界分支,剥离掉
- 最后考虑并行化——只有前两步做完还不够,且循环体够大,才上并行
下面这张图是我自己总结的决策流程,你可以参考:
- 循环剥离:解决边界分支,适合循环次数大、分支预测代价高的场景
- 循环向量化:利用 SIMD 指令,适合连续内存、无依赖的数据并行
- 循环并行化:利用多核,适合计算量大、迭代独立的场景