循环优化技术:循环展开、循环分块、循环交换、循环融合与分布

各位同学,今天我们来聊聊循环优化。说实话,在NPU集群上做编译优化,循环这块是绕不开的硬骨头。我这些年调过的模型,十有八九的性能瓶颈都卡在循环上。你想想看,一个深度学习模型里,卷积、矩阵乘、归一化……哪个不是循环套循环?

循环优化的核心目标就一个:让数据待在离计算单元最近的地方。说白了,就是尽量减少数据在内存和寄存器之间来回倒腾的次数。NPU集群跟CPU不一样,它的计算单元多、带宽有限,一旦数据搬运跟不上计算速度,整个集群都在空转——这就是我们常说的“内存墙”。

核心思想:循环优化的本质是“数据局部性”的挖掘。时间局部性(重复使用同一数据)和空间局部性(访问相邻数据)两手都要抓。

循环展开(Loop Unrolling)

循环展开是我用得最多的技巧之一。它的思路很简单:把循环体复制多份,减少循环控制的开销。

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

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

// 展开因子为4
for (int i = 0; i < 1024; 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];
}

你看,原来要执行1024次循环判断,现在只需要256次。我在项目中遇到过,有些NPU的循环控制单元特别弱,展开后性能直接翻倍。但要注意——展开不是越多越好。展开因子太大,寄存器压力会爆掉,反而导致寄存器溢出,性能断崖式下跌。

我的经验:展开因子一般取2、4、8。具体选哪个,要看NPU的寄存器文件大小和循环体内的变量数量。我习惯先做一次寄存器压力分析,再定展开因子。

避坑指南:我曾经在一个卷积核优化里把展开因子设成16,结果寄存器溢出导致性能比原始版本还差30%。后来老老实实做了寄存器压力分析,改成4倍展开,性能才提上来。

循环分块(Loop Tiling)

循环分块,说白了就是把大循环切成小块,让每一块的数据都能塞进缓存里。这在NPU集群上尤其重要——因为NPU的片上内存(SRAM)通常很小,但带宽极高。

矩阵乘法是经典案例:

// 原始三重循环
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];

// 分块后(块大小B)
for (int ii = 0; ii < N; ii += B)
    for (int jj = 0; jj < N; jj += B)
        for (int kk = 0; kk < N; kk += B)
            for (int i = ii; i < ii+B; i++)
                for (int j = jj; j < jj+B; j++)
                    for (int k = kk; k < kk+B; k++)
                        C[i][j] += A[i][k] * B[k][j];

分块后的代码,A、B、C三个矩阵的局部数据都能留在缓存里。我调过的一个ResNet-50模型,在华为昇腾910上做矩阵乘分块优化,性能提升了2.3倍。为什么?因为分块后数据搬运量大幅减少,计算单元不再干等数据。

块大小怎么选?一般取缓存容量的1/3到1/2。比如NPU的SRAM是256KB,每个数据是FP32(4字节),那块大小可以设为(256KB / 3 / 4B)的平方根,大约146。实际中我常用128或64,方便对齐。

循环交换(Loop Interchange)

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

看这个例子:

// 按行优先存储的数组,列优先访问——缓存不友好
for (int j = 0; j < N; j++)      // 外层循环是列
    for (int i = 0; i < M; i++)  // 内层循环是行
        sum += A[i][j];

// 交换后——行优先访问,缓存友好
for (int i = 0; i < M; i++)      // 外层循环是行
    for (int j = 0; j < N; j++)  // 内层循环是列
        sum += A[i][j];

为什么交换后性能好?因为C/C++的二维数组是按行存储的。原始版本每次访问A[i][j]都要跨行跳,缓存行利用率极低。交换后,内层循环连续访问同一行的相邻元素,一个缓存行能命中多次。

我记得有一次帮同事调一个图像处理算子,原始代码的循环顺序完全反了,交换后性能直接提升了5倍。他当时还以为是编译器自动优化了,我说:“编译器没那么聪明,它不敢随便交换循环——因为要保证语义等价。”

判断标准:内层循环的索引应该对应数组的最内层维度。对于多维数组,让内层循环的步长为1,这样内存访问就是连续的。

循环融合与分布(Loop Fusion & Distribution)

这两个是相反的操作。融合是把多个循环合并成一个,分布是把一个循环拆成多个。

循环融合的好处是:减少循环开销,提高数据重用率。

// 两个独立的循环
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]在寄存器里直接就被第二个语句用了,不用写回内存再读出来。我在做BERT模型优化时,把LayerNorm里的多个逐元素操作循环融合,减少了30%的数据搬运。

循环分布则相反:当一个循环体太大、寄存器压力过高时,拆成多个小循环反而更好。

// 循环体太大,寄存器压力高
for (int i = 0; i < N; i++) {
    // 20个不同的数组操作,需要大量寄存器
    a[i] = b[i] + c[i];
    d[i] = e[i] * f[i];
    g[i] = h[i] - i[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] = h[i] - i[i];

我曾经踩过的坑:有一次我把一个包含20个操作的循环强行融合,结果寄存器溢出导致性能下降40%。后来拆成4个小循环,每个循环5个操作,性能才恢复正常。所以融合和分布要权衡——融合看数据重用,分布看寄存器压力。

五种技术的协同使用

实际项目中,这五种技术很少单独使用。我一般会按这个顺序来优化:

  1. 先做循环交换——保证内存访问模式最优
  2. 再做循环分块——适配缓存/SRAM大小
  3. 然后做循环融合/分布——平衡数据重用和寄存器压力
  4. 最后做循环展开——减少循环控制开销

当然,这个顺序不是死的。有时候分块后自然就需要展开,有时候融合后又要重新分块。我习惯用性能分析工具(比如perf、rocprofiler)先跑一遍,看看瓶颈到底在哪——是缓存miss率高?还是指令开销大?还是寄存器溢出?然后对症下药。

一个真实案例:我在优化一个7B参数的大语言模型推理时,发现矩阵乘法的循环分块块大小设成64时性能最好。但配合循环展开(4倍展开)后,寄存器压力增加,又不得不把块大小降到48。最终经过十几轮调参,找到了块大小48、展开因子4的最优组合,推理速度提升了1.8倍。

知识体系总览

下面这张图总结了五种循环优化技术的核心逻辑和适用场景:

循环优化技术知识体系 循环优化 循环展开 循环分块 循环交换 循环融合 循环分布 互逆 优化目标:减少数据搬运 · 提高缓存命中率 · 降低循环开销 · 平衡寄存器压力 适用场景速查表: 技术 适用场景 风险 优先级 循环展开 循环开销大 寄存器溢出 中 循环分块 缓存miss率高 块大小难调 高 循环交换 内存访问不连续 语义等价性 最高

嗯,这张图把五种技术的关系和适用场景都串起来了。你想想看,实际调优时,循环交换往往是第一步——因为它不改变数据量,只改变访问模式,风险最低。循环分块和展开则需要结合硬件参数仔细调,风险高但收益也大。融合和分布是一对矛盾,要根据寄存器压力和缓存大小来权衡。

最后说一句:没有银弹。每种技术都有适用边界,关键是用性能分析工具说话。我见过太多人凭感觉调优,结果越调越慢。记住:先测量,再优化,再测量。

我的工具箱:在NPU集群上,我常用这些工具做循环优化分析——rocprof(AMD ROCm)、nsys(NVIDIA Nsight)、perf(Linux通用)。拿到缓存miss率、指令数、寄存器溢出次数这三个指标,基本就能定位问题。

想要更多资料,联系可可