想象你是一个每天要处理上百份文件、反复走同一条工作动线的案头工作者。日子久了,桌子上堆满了废纸,同一个动作你做了一遍又一遍——弯腰捡起一支笔、每一次都把水杯从左边挪到右边、每次开工前都要重新翻一遍字典才能找到第 5 个词。后来来了一位懂行的效率顾问,他不动任何业务逻辑,只帮你“整理工位”:把不随工作内容变化的物件固定放好、把繁琐的查找换成依次递进的计数、把零散的小任务合并成大块动作、把互不相干的工位拆开让不同的人同时干。于是同样的一天,你完成的活翻了一倍。 编译器里的“循环优化技术”,就是这位工位整理师。程序里 90% 的热点都藏在循环里,循环每多跑一遍,冗余动作就被放大一遍。本文要讲的循环不变量外提、归纳变量消除、循环展开、强度削弱、依赖分析与向量化,正是 LLVM、GCC 等编译器在你毫无察觉时替你完成的“整理工作”。读完之后,你会从“程序跑得快是玄学”变成“程序跑得快是工程”。

岗位关联

理解循环优化,是进入编译器后端优化管线的必修课。对 AI 工程师而言,它能帮你读懂深度学习编译器(TVM、XLA)的调度策略,也能让你在 CUDA/MLIR 编程时写出更容易被优化器“接住”的高性能代码。

目录
  1. 可视化演示
  2. 一、循环不变量外提:把不动的东西请出流水线
  3. 二、归纳变量消除:用计数器代替翻字典
  4. 三、循环展开:一把抱起四箱快递
  5. 四、强度削弱:用加法代替代价高昂的乘法
  6. 五、依赖与并行:先看谁等谁,再决定同时开工
  7. 六、向量化与融合:盖一个章代替盖一排章
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

循环是程序执行的热点,循环优化对性能提升至关重要。本文详解循环不变量外提、归纳变量消除、循环展开与强度削弱等经... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

循环依赖分析:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

一、循环不变量外提:把不动的东西请出流水线

整理工位时有个规律:如果一杯水从上班到下班都放在同一个位置,你绝不会每处理一份文件就把它端起来重新放一次。可惜很多程序就是这么写的——循环体里明明有一句“把水杯端到左边”的操作,而水杯从来没动过,于是每轮迭代都白白重复一次。

编译器把这种“每次迭代都一样、与循环变量无关”的表达式称为循环不变量。外提(Hoisting)就是把它从循环体搬到循环外面,只计算一次。看似简单,编译器却要谨慎:外提必须保证不会改变程序语义。比如循环可能一次都不执行,那么被外提的表达式也不能执行;又比如表达式可能触发除零错误,若把它提前到循环前执行,就会在原本不该出错的地方炸掉。因此编译器要用支配关系(Dominance)来确认:被外提的表达式所在的块,必须支配循环的所有出口,且目的块真正支配循环头。

以一个累加为例,若 $c$ 在循环内不随 $i$ 变化:

回到工位的比喻:外提好比把永远固定的台灯从“每次弯腰调整”改成“一开始就摆好”。优化的是频率最高的动作,收益随迭代次数放大,这也是它稳居所有循环优化之首的原因。

∑i=1n(ai+c) ⟹ n⋅c+∑i=1nai\displaystyle\sum_{i=1}^{n}(a_i + c)\ \Longrightarrow\ n \cdot c + \sum_{i=1}^{n} a_i
公式说明:把循环内每次都要加的 $c$ 提到循环外,先算 $n$ 个 $c$ 的总和,再统一加到最终结果上。
  1. 识别不变量$c$ 不依赖循环变量 $i$,它在每次迭代中取值恒定。
    c∝̸ic \not\propto i
  2. 重写累加项每个迭代的 $c$ 会被加一次,$n$ 次迭代共累加 $n \times c$。
    c+c+⋯+c⏟n 次=n⋅c\underbrace{c + c + \cdots + c}_{n\ \text{次}} = n \cdot c
  3. 外提到循环前将 $n \cdot c$ 放在循环前一次性算好,循环体只保留 $a_i$ 的累加。
    S=n⋅c;循环内: S+=aiS = n \cdot c;\quad \text{循环内: } S \mathrel{+}= a_i

二、归纳变量消除:用计数器代替翻字典

假设你的工作是按名单依次叫人,名单每隔 4 行才有一个名字,你每次都要从第 1 行开始数到当前行,才能找到下一个名字——这显然荒唐。聪明的做法是口袋里放一个计数器,每叫完一个人就加 4,永远不用重新数。

循环里的归纳变量就是这种“从第 1 行数起”的变量。设 $i$ 是循环变量(基本归纳变量),$t = 4i$ 由它线性派生而来(派生归纳变量)。编译器发现 $t$ 的值严格随 $i$ 推进后,会引入一个新变量 $t'$,让它在每个迭代自增 4,从而消灭乘法:

工位顾问的视角下,这就是“与其每次重新数,不如记住上次的位置”。归纳变量消除削减的不只是几条乘法指令,它让循环体更扁平,也为后续的循环展开和寄存器分配铺平了道路。

t=4i ⟹ t′=t′+4,t0′=0\displaystyle t = 4i \ \Longrightarrow\ t' = t' + 4,\quad t'_0 = 0
公式说明:把“每次用 $i$ 乘以 4 重新计算 $t$”替换为“$t$ 自己在上一轮基础上加 4”,初始化时从 0 开始。
  1. 识别线性关系发现 $t$ 是 $i$ 的线性函数 $t = 4i$,称 $t$ 为派生归纳变量。
    t=a⋅i+bt = a \cdot i + b
  2. 建立替代变量引入新变量 $t'$ 代替 $t$,初始值取 $i=0$ 时对应的 $t$。
    t0′=4×0=0t'_0 = 4 \times 0 = 0
  3. 更新步长$i$ 每增加 1,$t$ 增加 4,因此 $t'$ 每轮自增 4。
    tk′=tk−1′+4t'_{k} = t'_{k-1} + 4
  4. 删除旧定义所有使用 $t$ 处改用 $t'$,原 $t=4i$ 的乘法运算成为死代码并删除。
    删除 t=4×i\text{删除 } t = 4 \times i

三、循环展开:一把抱起四箱快递

快递员送快递,如果每送到一栋楼就要跑回三轮车取一件,那么 100 栋楼他要折返 100 次。聪明的做法是一次抱上 4 箱,沿路放下。循环展开(Loop Unrolling)就是这个道理:把原来迭代一次的循环体复制多份,然后一次迭代执行多次循环体的内容,从而减少“是否继续循环”的判断与跳转次数。

以展开因子 4 为例,将原本 $n$ 次迭代的循环改写为每轮处理 4 个元素:

展开不是越猛越好:代码体积膨胀会撑爆指令缓存,寄存器分配也会更紧张。这好比一次抱 50 箱快递,未到楼下先把自己累垮。编译器的展开因子通常由代价模型综合指令缓存容量、寄存器数量与数据依赖深度后决定。

∑i=14mf(i) ⟹ ∑j=0m−1(f(4j+1)+f(4j+2)+f(4j+3)+f(4j+4))\displaystyle \sum_{i=1}^{4m} f(i) \ \Longrightarrow\ \sum_{j=0}^{m-1}\big(f(4j+1)+f(4j+2)+f(4j+3)+f(4j+4)\big)
公式说明:假设 $n = 4m$,循环从迭代 $i$ 改为迭代 $j$,但每个 $j$ 里连续计算 $f$ 的 4 个下标值,循环控制次数从 $4m$ 降到 $m$。
  1. 确定展开因子选择 $K=4$,即一个迭代内做 4 份原始工作。
    K=4K = 4
  2. 按块重写循环原第 $i$ 次迭代对应新循环的 $j$,其中 $i = 4j + r,\ r \in \{1,2,3,4\}$。
    i∈{4j+1, 4j+2, 4j+3, 4j+4}i \in \{4j+1,\ 4j+2,\ 4j+3,\ 4j+4\}
  3. 合并循环体把 4 个 $f(i)$ 的调用连写在一个循环体内,循环条件判断次数减少到原来的 $1/4$。
    判断次数:4m→m\text{判断次数}: 4m \to m

四、强度削弱:用加法代替代价高昂的乘法

老花眼的人查词典时,要查“第 5 个词”得从第 1 个词数起,每次都做一次“从头数”的昂贵操作。更聪明的做法是记住上一次查到的位置,下一次只往后挪一格。强度削弱(Strength Reduction)做的正是这件事:把循环中昂贵的运算(乘法、除法、取模)替换为等价的、更廉价的增量运算。

典型的场景是数组寻址。设某次循环体内有 $i \times 2$,虽然它看似简单,但乘法在低端 CPU 上比加法慢 3-5 倍。编译器将其改写为增量变量 $t$:

强度削弱与归纳变量消除是孪生技术,常协同出现。从工位视角看,它是把“搬整箱水”换成“拧开龙头接一杯”——同样的结果,力气省了不止一点。现代 LLVM 的优化管线会在 SSA 形式下自动完成这一类重写,这也是为什么手写汇编往往跑不过 -O2。

i×2 ⟹ ti=ti−1+2\displaystyle i \times 2 \ \Longrightarrow\ t_i = t_{i-1} + 2
公式说明:维护一个辅助变量 $t$,$i=0$ 时 $t=0$,每次迭代 $t$ 自增 2,那么任意时刻 $t = i \times 2$ 恒成立,从而彻底消除乘法指令。
  1. 识别昂贵操作在循环体中找到 $i \times 2$ 这样的表达式,其中 $i$ 为循环变量。
    expr=i×2\text{expr} = i \times 2
  2. 构造递推关系因为 $i$ 每轮加 1,表达式每轮的增量恒为 2,于是用 $t$ 保存上一轮值。
    Δ=2,ti=ti−1+Δ\Delta = 2,\quad t_{i} = t_{i-1} + \Delta
  3. 初始化并替换在循环前设 $t_0 = 0$,循环内将原式替换为 $t$,删除乘法运算。
    t0=0;expr←tt_0 = 0;\quad \text{expr} \leftarrow t

五、依赖与并行:先看谁等谁,再决定同时开工

一个人搬砖快,一群人搬砖更快——但前提是大家手里的活互不牵制。如果第二个人必须等第一个人砌好墙角才能递砖,那并行就无从谈起。编译器想并行化循环,第一件事就是做循环依赖分析:判断不同迭代之间是否存在数据上的先后关系。

最常用的刻画工具是距离向量。对于循环:

第 $i$ 次迭代读写 $A[i]$ 与 $A[i-1]$,而 $A[i-1]$ 恰好是上一迭代($i-1$)写的,因此迭代之间存在“等前一迭代”的依赖,距离为 1:

反过来,如果每个迭代只读写自己的 $A[i]$,距离向量为 $0$ 或相互独立,编译器就会放心地做多核并行或 SIMD。依赖分析是整个循环优化的“安全检查员”——它决定了并行与向量化的合法边界。

A[i]=A[i−1]+1 ⇒ d⃗=(1)\displaystyle A[i] = A[i-1] + 1\ \Rightarrow\ \vec{d} = (1)
公式说明:距离向量 $d$ 表示当前迭代访问的数组下标与之前某迭代访问下标的差。$d=1$ 意味着每步都依赖前一步,这种循环无法直接并行。
  1. 写出访问对找循环内同一数组的两次访问 $A[i]$ 与 $A[i-1]$,$i$ 随迭代递增。
    写入: A[i],读取: A[i−1]\text{写入: } A[i],\quad \text{读取: } A[i-1]
  2. 计算距离向量用当前下标减先前下标,$i - (i-1) = 1$,得到 $\vec{d} = (1)$。
    d⃗=i−(i−1)=1\vec{d} = i - (i-1) = 1
  3. 判断依赖方向$d > 0$ 表示后一迭代依赖前一迭代的写入,形成循环携带依赖,不能并行。
    d>0⇒串行d > 0 \Rightarrow \text{串行}
c2 行
for (i = 1; i < n; i++)    A[i] = A[i-1] + 1;

六、向量化与融合:盖一个章代替盖一排章

批量盖章时,逐张盖 100 个章费时费力,换成定制一个大印章一次盖一排,效率立升。循环向量化(Vectorization)就是让 CPU 用一条 SIMD(单指令多数据)指令同时对多个数据执行同一操作。它的前提依然是依赖分析:迭代之间互不干扰,才能把多个标量迭代打包:

除向量化外,循环融合(Fusion)与循环分裂(Fission)也是整理工位的好手段:两个循环遍历同一数组时,融合成一个循环能减少一次整趟内存搬运,提升数据局部性;反过来,一个循环体过于臃肿或存在部分依赖时,拆成多个循环反而能暴露并行机会。现代编译器像一位精准的工位规划师,反复权衡“哪些工序该合并、哪些该拆开、哪些该打包批量处理”。

C[i]=A[i]+B[i] ⟹ C⃗=A⃗+B⃗\displaystyle C[i] = A[i] + B[i]\ \Longrightarrow\ \vec{C} = \vec{A} + \vec{B}
公式说明:原本要对数组的每个下标逐元素相加,向量化后变成一条指令同时完成 4 个或 8 个元素的加法(取决于 SIMD 寄存器宽度)。
  1. 确认无依赖第 $i$ 次迭代只读写下标 $i$,迭代间无数据依赖,距离向量为 $0$。
    d⃗=(0)\vec{d} = (0)
  2. 打包数据将 $A[i..i+3]$ 与 $B[i..i+3]$ 分别装入向量寄存器。
    A⃗=(Ai,Ai+1,Ai+2,Ai+3)\vec{A} = (A_i, A_{i+1}, A_{i+2}, A_{i+3})
  3. 单指令计算执行一条向量加法指令,结果一次性写入 $C$ 的对应 4 个位置。
    C⃗=A⃗+B⃗\vec{C} = \vec{A} + \vec{B}

AI 应用场景

场景一:深度学习编译器中的算子循环优化

阶段:模型部署与推理优化

解决的问题:深度学习模型(卷积、矩阵乘)底层是嵌套多层循环,朴素实现推理延迟高。TVM、MLIR 等编译器要对算子做循环分块(Tiling)、向量化和强度削弱,才能把推理速度提升 5-20 倍。AI 工程师若不懂循环优化,写出的自定义算子往往在编译后仍跑不出理想性能。

具体案例:某推理团队部署 YOLO 模型时,发现卷积算子在 CPU 上仅用了 30% 的算力。利用 TVM 的调度原语对卷积循环做展开(因子 4)和内层向量化(AVX2 8 通道),配合循环融合将三层的中间结果留在寄存器中,端到端推理延迟从 40ms 降到 9ms。

场景二:LLVM 优化管线在数据库引擎中的应用

阶段:系统性能优化(非 AI 场景,但同属编译后端)

解决的问题:数据库执行引擎的表达式计算、哈希连接等热点路劲由大循环构成。传统手写优化难维护、易出错,LLVM 的 -O2 优化管线能自动完成不变量外提、强度削弱、循环向量化,让热点路径接近手写汇编的性能。

具体案例:DuckDB 列式引擎将 WHERE 过滤表达式编译为 LLVM IR 后再做循环优化,使单线程扫描速度达到每秒数亿行。得益于循环不变量外提,谓词中的常量比较被提前计算,过滤循环体进一步瘦身,整体查询耗时降低 46%。

Python 演示

python49 行
import time N = 10_000_000 def baseline(n):    total = 0    for i in range(n):        total += i * 2 + 1000    return total def hoist_invariant(n):    total = 0    for i in range(n):        total += i * 2    return total + n * 1000 def strength_reduce(n):    total = 0    t = 0    for i in range(n):        total += t        t += 2    return total + n * 1000 def unroll4(n):    total = 0    t = 0    for i in range(0, n, 4):        total += t        t += 2        total += t        t += 2        total += t        t += 2        total += t        t += 2    return total + n * 1000 def time_it(fn):    t0 = time.perf_counter()    r = fn()    return time.perf_counter() - t0, r if __name__ == '__main__':    ans = baseline(N)    for fn in [baseline, hoist_invariant, strength_reduce, unroll4]:        dt, res = time_it(fn)        print(f"{fn.__name__:>16}  {dt*1000:7.2f} ms  result={res}")        assert res == ans, fn.__name__
目的:逐级施加编译器优化手法,观察同一计算在不同优化级别下的耗时差异,验证结果一致性。
  1. for i in range(n):朴素的循环骨架,每次迭代都执行完整计算。
  2. total += i * 2 + 1000原始写法,1000 是不变量,每轮迭代都重复加法。
  3. return total + n * 1000不变量外提:循环内不再计算 1000,循环结束后一次性加上。
  4. t += 2强度削弱:用增量加法替代 i * 2 的乘法运算。
  5. for i in range(0, n, 4):循环展开:步长 4,一次迭代做 4 份工作,减少分支判断。
  6. assert res == ans, fn.__name__验证优化后的结果与朴素版本完全一致,保证不改变语义。

常见误区

误区我觉得循环展开就是“一次做更多事”,展开因子越大,速度肯定越快。编译器为什么不默认全部展开成几十份呢?

正解展开减少了循环控制开销,但代价是代码体积膨胀。现代 CPU 的指令缓存(L1I)极小,一旦展开后的代码超出缓存容量,CPU 频繁 miss,性能会断崖式下跌。展开因子还需要配合寄存器轮换与指令调度,否则引入大量寄存器溢出(spill),反而更慢。好比一次抱 50 箱快递,还没走到楼下手先酸了。

为何常见:初学者只看到“减少循环次数”的表面收益,忽略了存储体系与寄存器压力的隐形成本,所以容易把展开因子看作越大越好。

误区既然是不变量,把它提到循环外执行肯定没问题,反正每次算出来都一样,甚至更早算出结果不是很好吗?

正解外提会改变计算的执行时机与次数,从而可能改变程序行为。例如循环有可能零次执行,原程序不执行相关计算;一旦外提到循环前,相当于强制执行一次。更隐蔽的是浮点运算:循环内计算结果因舍入误差不同,可能在浮点比较时与原语义产生偏差。所以编译器必须用支配关系验证安全性,不是“看起来不变就提”。

为何常见:人们习惯用数学中的等价替换来思考,忽略了程序语义中的“执行时机”和“异常模型”,这是编译器前端转向后端时最大的思维转变。

误区我看到“向量化”三个字就以为是把循环拆到多个 CPU 核心上跑,跟 Python 里开多进程差不多。

正解向量化是数据级并行(SIMD),一条指令同时处理多个数据,发生在单个核心内部;多线程是任务级并行(MIMD),多个核心同时执行不同指令流。向量化要求数据无依赖且内存连续,多线程要求迭代间无依赖但不必连续。两者经常配合,比如先多线程切分迭代区间,再把每段内的循环向量化。

为何常见:日常接触到“并行”一词多指多核/多线程,导致默认把向量化归入同一类别,忽略了 SIMD 是 CPU 指令集层面的并行。

误区现代编译器 O2 已经很强,我把代码写好交给编译器就行,循环优化这些细节没必要了解。

正解编译器自动优化依赖源码的可分析性。如果程序员写出带别名(alias)的指针、跨迭代的数据依赖、过长的循环体,优化器会因无法证明安全性而直接放弃。理解循环优化原理,才能写出“对优化器友好”的代码,比如用 restrict 声明消除别名,用局部变量累加帮助不变量外提。在 AI 编译器中,调度仍需人工指定 tile/unroll 因子,完全不懂循环优化将寸步难行。

为何常见:现代工具的易用性掩盖了其背后的保守策略,人们误以为“全自动”等于“无条件生效”。

面试题

Q1. 循环不变量外提的核心做法是什么?

单选题 · easy

  • A.把循环内与迭代无关的计算移到循环外← 正确答案
  • B.把所有变量声明移到循环外
  • C.减少循环的迭代次数
  • D.增加一个循环变量

答案:A。不变量外提针对的是“每次迭代结果相同”的表达式,与循环变量无关,把它挪到循环前只执行一次。B 是变量声明问题,C 是循环展开,D 是归纳变量思路,均不符。

Q2. 下列哪个变换属于强度削弱?

单选题 · easy

  • A.将 i * 2 改为维护变量 t += 2← 正确答案
  • B.将循环体复制成四份
  • C.把常量移到循环外
  • D.函数内联

答案:A。强度削弱用廉价运算替代昂贵运算,乘法变加法增量正是典型。B 是循环展开,C 是不变量外提,D 是过程间优化,均不属强度削弱。

Q3. 归纳变量消除的主要收益是什么?

单选题 · easy

  • A.用加法递推替代每次迭代的乘法计算← 正确答案
  • B.删除整个循环
  • C.增加中间变量数量
  • D.让循环体变长

答案:A。归纳变量(如 t = 4*i)随循环变量线性变化,编译器用自增变量替代它,消除乘法。B 是死循环删除的范畴;C 是副作用而非收益;D 是循环展开的特征。

Q4. 循环展开因子过大时,最可能带来什么问题?

单选题 · mid

  • A.指令缓存压力增大,代码膨胀拖慢执行← 正确答案
  • B.循环次数会增加
  • C.计算结果必然出错
  • D.分支预测完全失效

答案:A。展开因子越大,复制出的循环体代码越多,超过 L1 指令缓存容量后 miss 率升高,反而变慢。循环次数不会增加,结果在正确实现下不变,分支预测受影响但不必然失效,因此 B、C、D 不成立。

Q5. 距离向量 d = 1 表示什么含义?

单选题 · mid

  • A.迭代 i 依赖迭代 i-1,构成循环携带依赖,难以直接并行← 正确答案
  • B.所有迭代完全独立,可以无限并行
  • C.循环不存在依赖
  • D.循环变量恒等于 1

答案:A。距离向量 d = 1 说明当前迭代读写的数组位置与前一轮有交叠,每步都等上一步结果。这种串行链无法直接并行化。B、C 与距离为 0 对应,D 曲解了距离向量的含义。

Q6. 循环向量化(SIMD)的硬性前提是什么?

单选题 · mid

  • A.迭代间无数据依赖,且访存尽量连续← 正确答案
  • B.循环次数必须是 8 的倍数
  • C.循环体内必须没有加法
  • D.编译器必须开启内联

答案:A。向量化把多个迭代打包成一条指令执行,若迭代间有依赖则打包后结果错乱;内存连续有助于装载向量寄存器。循环次数不要求固定倍数(可处理尾部),循环体有无加法均无关,内联不是必要条件。

Q7. 哪种情况下,循环不变量外提会改变程序语义?

单选题 · extreme

  • A.被外提的计算可能触发异常,而原循环可能零次执行该路径← 正确答案
  • B.不变量是整型常量
  • C.循环体内只有一条语句
  • D.循环变量可以被外提变量替换

答案:A。若循环可能不执行,则原程序根本不会执行该计算;外提后提前执行了一次,异常时机与副作用都变了,属于不安全的变换。整型常量外提无风险;循环体长短与外提语义无关;替换循环变量属于归纳变量消除。

Q8. 下列哪些是本文介绍的循环优化技术?

多选题 · mid

  • A.循环不变量外提← 正确答案
  • B.归纳变量消除← 正确答案
  • C.死代码删除
  • D.循环展开← 正确答案

答案:ABD。不变量外提、归纳变量消除、循环展开都是本文核心内容。死代码删除是通用优化,并非循环特化技术,因此在本文知识边界内不选 C。

Q9. 关于循环展开,下列说法正确的有?

多选题 · mid

  • A.能减少循环条件判断次数← 正确答案
  • B.可能增加寄存器压力← 正确答案
  • C.一定会缩短运行时间
  • D.需要处理迭代次数不被因子整除的尾部逻辑← 正确答案

答案:ABD。展开通过复制循环体减少回边跳转,因此 A 正确;多份循环体同时活跃会占用更多寄存器,B 正确;展开可能因缓存溢出而变慢,C 过于绝对;循环次数不是展开因子倍数时需要处理剩余迭代,D 正确。

Q10. 哪些因素会阻碍循环向量化?

多选题 · hard

  • A.迭代之间存在循环携带依赖← 正确答案
  • B.内存访问不连续(stride 较大)← 正确答案
  • C.循环内完全没有数组操作
  • D.目标 CPU 不支持所需 SIMD 指令集← 正确答案

答案:ABD。循环携带依赖使打包执行出错,A 当选;不连续访存无法高效装载向量寄存器,B 当选;没有数组操作时如涉及标量链也能向量化,C 不是阻碍;指令集不支持则无法生成向量指令,D 当选。