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芯片对循环展开特别敏感,展开后性能直接翻倍。

小技巧:展开因子不是越大越好。一般建议展开2-4倍,展开太多会导致代码膨胀,反而影响指令缓存的命中率。

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];
                    }
                }
            }
        }
    }
}
关键点:分块大小要匹配目标芯片的缓存行大小。比如Cortex-M系列,L1缓存通常是32KB,分块大小设为16或32比较合适。

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 三种技术的组合使用

在实际项目中,这三种技术往往要组合使用。我建议的顺序是:

  1. 先做循环重排——调整访问模式,确保内存访问连续
  2. 再做循环分块——让数据适配缓存大小
  3. 最后做循环展开——减少循环开销

举个例子,一个典型的卷积层优化流程:

// 第一步:重排,把通道维放到最内层
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倍。说实话,我自己都没想到效果这么好。

最后提醒一句:优化前一定要先profile。我见过太多人一上来就各种优化,结果发现瓶颈根本不在循环上。用性能分析工具看看热点在哪里,再对症下药。

好了,循环优化的三个核心技巧就聊到这里。记住,没有银弹,每种技术都有它的适用场景。多实践、多测量,慢慢你就能找到感觉了。