4、循环优化:循环展开、循环分块、循环重排
说到循环优化,我得先坦白一件事。刚入行那会儿,我总觉得编译器能搞定一切。直到有一次,我在一个视频编解码项目里,发现一个简单的三层循环跑得比乌龟还慢。嗯,从那以后,我再也不敢小看循环优化了。
说白了,嵌入式AI开发里,90%的计算时间都花在循环上。卷积、池化、矩阵乘,哪个不是循环套循环?你想想看,如果能把循环效率提升10倍,整个模型推理速度就能翻一番。今天我们就聊聊三个最实用的循环优化技巧。
4.1 循环展开:用空间换时间
循环展开,说白了就是减少循环次数,把多次迭代的内容直接写出来。为什么要这么做?因为每次循环都有开销——条件判断、计数器递增、跳转指令。这些开销在循环次数少的时候无所谓,但几百万次迭代下来,积少成多。
我个人习惯,在嵌入式芯片上,循环展开至少能带来20%-50%的性能提升。来看个例子:
// 原始循环
for (int i = 0; i < 8; i++) {
c[i] = a[i] + b[i];
}
// 展开4次
for (int i = 0; i < 8; 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];
}
你看,展开后的代码虽然长了点,但循环次数从8次减到了2次。我在项目中遇到过,有些DSP芯片对循环展开特别敏感,展开后性能直接翻倍。
4.2 循环分块:让数据留在缓存里
循环分块,这个名字听起来有点玄乎。其实核心思想很简单:把大数据集切成小块,让每一块都能塞进CPU的缓存里。
为什么会这样?因为嵌入式芯片的缓存通常很小,可能只有32KB或64KB。如果你一次性处理整个大矩阵,数据在内存和缓存之间来回搬运,大部分时间都浪费在等待上了。
我记得有一次优化一个3x3的卷积操作,原始代码是这样的:
// 原始卷积,未分块
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
for (int ki = -1; ki <= 1; ki++) {
for (int kj = -1; kj <= 1; kj++) {
output[i][j] += input[i+ki][j+kj] * kernel[ki+1][kj+1];
}
}
}
}
这个代码的问题在于,每次访问input[i+ki][j+kj]都可能触发缓存缺失。我改成循环分块后:
// 分块后的卷积
int block_size = 16; // 根据缓存大小调整
for (int ii = 0; ii < height; ii += block_size) {
for (int jj = 0; jj < width; jj += block_size) {
for (int i = ii; i < min(ii+block_size, height); i++) {
for (int j = jj; j < min(jj+block_size, width); j++) {
for (int ki = -1; ki <= 1; ki++) {
for (int kj = -1; kj <= 1; kj++) {
output[i][j] += input[i+ki][j+kj] * kernel[ki+1][kj+1];
}
}
}
}
}
}
4.3 循环重排:调整访问顺序
循环重排,说白了就是改变循环的嵌套顺序。为什么顺序这么重要?因为内存访问模式决定了缓存利用率。
你想想看,C/C++里二维数组是按行存储的。如果你按列访问,每次都要跳一大段内存,缓存命中率自然就低了。
我曾经在一个图像处理项目里,遇到过这样的代码:
// 糟糕的访问模式:按列访问
for (int j = 0; j < cols; j++) {
for (int i = 0; i < rows; i++) {
sum += matrix[i][j]; // 每次访问都跨行
}
}
// 优化后的访问模式:按行访问
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
sum += matrix[i][j]; // 连续内存访问
}
}
仅仅是把内外层循环交换了一下,性能提升了3倍。嗯,这就是循环重排的威力。
| 优化技术 | 适用场景 | 预期提升 | 注意事项 |
|---|---|---|---|
| 循环展开 | 循环次数固定、循环体较小 | 20%-50% | 注意代码膨胀 |
| 循环分块 | 大数据集、缓存敏感型算法 | 2-10倍 | 分块大小需调参 |
| 循环重排 | 多维数组遍历 | 2-5倍 | 注意数据依赖 |
4.4 三种技术的组合使用
在实际项目中,这三种技术往往要组合使用。我建议的顺序是:
- 先做循环重排——调整访问模式,确保内存访问连续
- 再做循环分块——让数据适配缓存大小
- 最后做循环展开——减少循环开销
举个例子,一个典型的卷积层优化流程:
// 第一步:重排,把通道维放到最内层
for (int h = 0; h < H; h++) {
for (int w = 0; w < W; w++) {
for (int c = 0; c < C; c++) {
// 处理...
}
}
}
// 第二步:分块,按16x16分块
int bh = 16, bw = 16;
for (int hh = 0; hh < H; hh += bh) {
for (int ww = 0; ww < W; ww += bw) {
for (int h = hh; h < hh+bh; h++) {
for (int w = ww; w < ww+bw; w++) {
for (int c = 0; c < C; c++) {
// 处理...
}
}
}
}
}
// 第三步:展开最内层循环
for (int hh = 0; hh < H; hh += bh) {
for (int ww = 0; ww < W; ww += bw) {
for (int h = hh; h < hh+bh; h++) {
for (int w = ww; w < ww+bw; w++) {
int c = 0;
for (; c < C-3; c += 4) {
// 展开4次
process(h, w, c);
process(h, w, c+1);
process(h, w, c+2);
process(h, w, c+3);
}
for (; c < C; c++) {
process(h, w, c);
}
}
}
}
}
这个优化后的版本,在我测试过的Cortex-M7芯片上,比原始版本快了将近8倍。说实话,我自己都没想到效果这么好。
好了,循环优化的三个核心技巧就聊到这里。记住,没有银弹,每种技术都有它的适用场景。多实践、多测量,慢慢你就能找到感觉了。