想象你是一个每天要处理上百份文件、反复走同一条工作动线的案头工作者。日子久了,桌子上堆满了废纸,同一个动作你做了一遍又一遍——弯腰捡起一支笔、每一次都把水杯从左边挪到右边、每次开工前都要重新翻一遍字典才能找到第 5 个词。后来来了一位懂行的效率顾问,他不动任何业务逻辑,只帮你“整理工位”:把不随工作内容变化的物件固定放好、把繁琐的查找换成依次递进的计数、把零散的小任务合并成大块动作、把互不相干的工位拆开让不同的人同时干。于是同样的一天,你完成的活翻了一倍。 编译器里的“循环优化技术”,就是这位工位整理师。程序里 90% 的热点都藏在循环里,循环每多跑一遍,冗余动作就被放大一遍。本文要讲的循环不变量外提、归纳变量消除、循环展开、强度削弱、依赖分析与向量化,正是 LLVM、GCC 等编译器在你毫无察觉时替你完成的“整理工作”。读完之后,你会从“程序跑得快是玄学”变成“程序跑得快是工程”。
理解循环优化,是进入编译器后端优化管线的必修课。对 AI 工程师而言,它能帮你读懂深度学习编译器(TVM、XLA)的调度策略,也能让你在 CUDA/MLIR 编程时写出更容易被优化器“接住”的高性能代码。
目录
可视化演示
循环是程序执行的热点,循环优化对性能提升至关重要。本文详解循环不变量外提、归纳变量消除、循环展开与强度削弱等经... 切换模式查看不同维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
循环依赖分析:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现
一、循环不变量外提:把不动的东西请出流水线
整理工位时有个规律:如果一杯水从上班到下班都放在同一个位置,你绝不会每处理一份文件就把它端起来重新放一次。可惜很多程序就是这么写的——循环体里明明有一句“把水杯端到左边”的操作,而水杯从来没动过,于是每轮迭代都白白重复一次。
编译器把这种“每次迭代都一样、与循环变量无关”的表达式称为循环不变量。外提(Hoisting)就是把它从循环体搬到循环外面,只计算一次。看似简单,编译器却要谨慎:外提必须保证不会改变程序语义。比如循环可能一次都不执行,那么被外提的表达式也不能执行;又比如表达式可能触发除零错误,若把它提前到循环前执行,就会在原本不该出错的地方炸掉。因此编译器要用支配关系(Dominance)来确认:被外提的表达式所在的块,必须支配循环的所有出口,且目的块真正支配循环头。
以一个累加为例,若 $c$ 在循环内不随 $i$ 变化:
回到工位的比喻:外提好比把永远固定的台灯从“每次弯腰调整”改成“一开始就摆好”。优化的是频率最高的动作,收益随迭代次数放大,这也是它稳居所有循环优化之首的原因。
- 识别不变量$c$ 不依赖循环变量 $i$,它在每次迭代中取值恒定。
- 重写累加项每个迭代的 $c$ 会被加一次,$n$ 次迭代共累加 $n \times c$。
- 外提到循环前将 $n \cdot c$ 放在循环前一次性算好,循环体只保留 $a_i$ 的累加。
二、归纳变量消除:用计数器代替翻字典
假设你的工作是按名单依次叫人,名单每隔 4 行才有一个名字,你每次都要从第 1 行开始数到当前行,才能找到下一个名字——这显然荒唐。聪明的做法是口袋里放一个计数器,每叫完一个人就加 4,永远不用重新数。
循环里的归纳变量就是这种“从第 1 行数起”的变量。设 $i$ 是循环变量(基本归纳变量),$t = 4i$ 由它线性派生而来(派生归纳变量)。编译器发现 $t$ 的值严格随 $i$ 推进后,会引入一个新变量 $t'$,让它在每个迭代自增 4,从而消灭乘法:
工位顾问的视角下,这就是“与其每次重新数,不如记住上次的位置”。归纳变量消除削减的不只是几条乘法指令,它让循环体更扁平,也为后续的循环展开和寄存器分配铺平了道路。
- 识别线性关系发现 $t$ 是 $i$ 的线性函数 $t = 4i$,称 $t$ 为派生归纳变量。
- 建立替代变量引入新变量 $t'$ 代替 $t$,初始值取 $i=0$ 时对应的 $t$。
- 更新步长$i$ 每增加 1,$t$ 增加 4,因此 $t'$ 每轮自增 4。
- 删除旧定义所有使用 $t$ 处改用 $t'$,原 $t=4i$ 的乘法运算成为死代码并删除。
三、循环展开:一把抱起四箱快递
快递员送快递,如果每送到一栋楼就要跑回三轮车取一件,那么 100 栋楼他要折返 100 次。聪明的做法是一次抱上 4 箱,沿路放下。循环展开(Loop Unrolling)就是这个道理:把原来迭代一次的循环体复制多份,然后一次迭代执行多次循环体的内容,从而减少“是否继续循环”的判断与跳转次数。
以展开因子 4 为例,将原本 $n$ 次迭代的循环改写为每轮处理 4 个元素:
展开不是越猛越好:代码体积膨胀会撑爆指令缓存,寄存器分配也会更紧张。这好比一次抱 50 箱快递,未到楼下先把自己累垮。编译器的展开因子通常由代价模型综合指令缓存容量、寄存器数量与数据依赖深度后决定。
- 确定展开因子选择 $K=4$,即一个迭代内做 4 份原始工作。
- 按块重写循环原第 $i$ 次迭代对应新循环的 $j$,其中 $i = 4j + r,\ r \in \{1,2,3,4\}$。
- 合并循环体把 4 个 $f(i)$ 的调用连写在一个循环体内,循环条件判断次数减少到原来的 $1/4$。
四、强度削弱:用加法代替代价高昂的乘法
老花眼的人查词典时,要查“第 5 个词”得从第 1 个词数起,每次都做一次“从头数”的昂贵操作。更聪明的做法是记住上一次查到的位置,下一次只往后挪一格。强度削弱(Strength Reduction)做的正是这件事:把循环中昂贵的运算(乘法、除法、取模)替换为等价的、更廉价的增量运算。
典型的场景是数组寻址。设某次循环体内有 $i \times 2$,虽然它看似简单,但乘法在低端 CPU 上比加法慢 3-5 倍。编译器将其改写为增量变量 $t$:
强度削弱与归纳变量消除是孪生技术,常协同出现。从工位视角看,它是把“搬整箱水”换成“拧开龙头接一杯”——同样的结果,力气省了不止一点。现代 LLVM 的优化管线会在 SSA 形式下自动完成这一类重写,这也是为什么手写汇编往往跑不过 -O2。
- 识别昂贵操作在循环体中找到 $i \times 2$ 这样的表达式,其中 $i$ 为循环变量。
- 构造递推关系因为 $i$ 每轮加 1,表达式每轮的增量恒为 2,于是用 $t$ 保存上一轮值。
- 初始化并替换在循环前设 $t_0 = 0$,循环内将原式替换为 $t$,删除乘法运算。
五、依赖与并行:先看谁等谁,再决定同时开工
一个人搬砖快,一群人搬砖更快——但前提是大家手里的活互不牵制。如果第二个人必须等第一个人砌好墙角才能递砖,那并行就无从谈起。编译器想并行化循环,第一件事就是做循环依赖分析:判断不同迭代之间是否存在数据上的先后关系。
最常用的刻画工具是距离向量。对于循环:
第 $i$ 次迭代读写 $A[i]$ 与 $A[i-1]$,而 $A[i-1]$ 恰好是上一迭代($i-1$)写的,因此迭代之间存在“等前一迭代”的依赖,距离为 1:
反过来,如果每个迭代只读写自己的 $A[i]$,距离向量为 $0$ 或相互独立,编译器就会放心地做多核并行或 SIMD。依赖分析是整个循环优化的“安全检查员”——它决定了并行与向量化的合法边界。
- 写出访问对找循环内同一数组的两次访问 $A[i]$ 与 $A[i-1]$,$i$ 随迭代递增。
- 计算距离向量用当前下标减先前下标,$i - (i-1) = 1$,得到 $\vec{d} = (1)$。
- 判断依赖方向$d > 0$ 表示后一迭代依赖前一迭代的写入,形成循环携带依赖,不能并行。
for (i = 1; i < n; i++) A[i] = A[i-1] + 1;
六、向量化与融合:盖一个章代替盖一排章
批量盖章时,逐张盖 100 个章费时费力,换成定制一个大印章一次盖一排,效率立升。循环向量化(Vectorization)就是让 CPU 用一条 SIMD(单指令多数据)指令同时对多个数据执行同一操作。它的前提依然是依赖分析:迭代之间互不干扰,才能把多个标量迭代打包:
除向量化外,循环融合(Fusion)与循环分裂(Fission)也是整理工位的好手段:两个循环遍历同一数组时,融合成一个循环能减少一次整趟内存搬运,提升数据局部性;反过来,一个循环体过于臃肿或存在部分依赖时,拆成多个循环反而能暴露并行机会。现代编译器像一位精准的工位规划师,反复权衡“哪些工序该合并、哪些该拆开、哪些该打包批量处理”。
- 确认无依赖第 $i$ 次迭代只读写下标 $i$,迭代间无数据依赖,距离向量为 $0$。
- 打包数据将 $A[i..i+3]$ 与 $B[i..i+3]$ 分别装入向量寄存器。
- 单指令计算执行一条向量加法指令,结果一次性写入 $C$ 的对应 4 个位置。
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 演示
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__
for i in range(n):朴素的循环骨架,每次迭代都执行完整计算。total += i * 2 + 1000原始写法,1000 是不变量,每轮迭代都重复加法。return total + n * 1000不变量外提:循环内不再计算 1000,循环结束后一次性加上。t += 2强度削弱:用增量加法替代i * 2的乘法运算。for i in range(0, n, 4):循环展开:步长 4,一次迭代做 4 份工作,减少分支判断。assert res == ans, fn.__name__验证优化后的结果与朴素版本完全一致,保证不改变语义。
常见误区
误区我觉得循环展开就是“一次做更多事”,展开因子越大,速度肯定越快。编译器为什么不默认全部展开成几十份呢?
正解展开减少了循环控制开销,但代价是代码体积膨胀。现代 CPU 的指令缓存(L1I)极小,一旦展开后的代码超出缓存容量,CPU 频繁 miss,性能会断崖式下跌。展开因子还需要配合寄存器轮换与指令调度,否则引入大量寄存器溢出(spill),反而更慢。好比一次抱 50 箱快递,还没走到楼下手先酸了。
为何常见:初学者只看到“减少循环次数”的表面收益,忽略了存储体系与寄存器压力的隐形成本,所以容易把展开因子看作越大越好。
误区既然是不变量,把它提到循环外执行肯定没问题,反正每次算出来都一样,甚至更早算出结果不是很好吗?
正解外提会改变计算的执行时机与次数,从而可能改变程序行为。例如循环有可能零次执行,原程序不执行相关计算;一旦外提到循环前,相当于强制执行一次。更隐蔽的是浮点运算:循环内计算结果因舍入误差不同,可能在浮点比较时与原语义产生偏差。所以编译器必须用支配关系验证安全性,不是“看起来不变就提”。
为何常见:人们习惯用数学中的等价替换来思考,忽略了程序语义中的“执行时机”和“异常模型”,这是编译器前端转向后端时最大的思维转变。
误区我看到“向量化”三个字就以为是把循环拆到多个 CPU 核心上跑,跟 Python 里开多进程差不多。
正解向量化是数据级并行(SIMD),一条指令同时处理多个数据,发生在单个核心内部;多线程是任务级并行(MIMD),多个核心同时执行不同指令流。向量化要求数据无依赖且内存连续,多线程要求迭代间无依赖但不必连续。两者经常配合,比如先多线程切分迭代区间,再把每段内的循环向量化。
为何常见:日常接触到“并行”一词多指多核/多线程,导致默认把向量化归入同一类别,忽略了 SIMD 是 CPU 指令集层面的并行。
误区现代编译器 O2 已经很强,我把代码写好交给编译器就行,循环优化这些细节没必要了解。
正解编译器自动优化依赖源码的可分析性。如果程序员写出带别名(alias)的指针、跨迭代的数据依赖、过长的循环体,优化器会因无法证明安全性而直接放弃。理解循环优化原理,才能写出“对优化器友好”的代码,比如用 restrict 声明消除别名,用局部变量累加帮助不变量外提。在 AI 编译器中,调度仍需人工指定 tile/unroll 因子,完全不懂循环优化将寸步难行。
为何常见:现代工具的易用性掩盖了其背后的保守策略,人们误以为“全自动”等于“无条件生效”。
面试题
Q1. 循环不变量外提的核心做法是什么?
- A.把循环内与迭代无关的计算移到循环外← 正确答案
- B.把所有变量声明移到循环外
- C.减少循环的迭代次数
- D.增加一个循环变量
答案:A。不变量外提针对的是“每次迭代结果相同”的表达式,与循环变量无关,把它挪到循环前只执行一次。B 是变量声明问题,C 是循环展开,D 是归纳变量思路,均不符。
Q2. 下列哪个变换属于强度削弱?
- A.将
i * 2改为维护变量t += 2← 正确答案 - B.将循环体复制成四份
- C.把常量移到循环外
- D.函数内联
答案:A。强度削弱用廉价运算替代昂贵运算,乘法变加法增量正是典型。B 是循环展开,C 是不变量外提,D 是过程间优化,均不属强度削弱。
Q3. 归纳变量消除的主要收益是什么?
- A.用加法递推替代每次迭代的乘法计算← 正确答案
- B.删除整个循环
- C.增加中间变量数量
- D.让循环体变长
答案:A。归纳变量(如 t = 4*i)随循环变量线性变化,编译器用自增变量替代它,消除乘法。B 是死循环删除的范畴;C 是副作用而非收益;D 是循环展开的特征。
Q4. 循环展开因子过大时,最可能带来什么问题?
- A.指令缓存压力增大,代码膨胀拖慢执行← 正确答案
- B.循环次数会增加
- C.计算结果必然出错
- D.分支预测完全失效
答案:A。展开因子越大,复制出的循环体代码越多,超过 L1 指令缓存容量后 miss 率升高,反而变慢。循环次数不会增加,结果在正确实现下不变,分支预测受影响但不必然失效,因此 B、C、D 不成立。
Q5. 距离向量 d = 1 表示什么含义?
- A.迭代 i 依赖迭代 i-1,构成循环携带依赖,难以直接并行← 正确答案
- B.所有迭代完全独立,可以无限并行
- C.循环不存在依赖
- D.循环变量恒等于 1
答案:A。距离向量 d = 1 说明当前迭代读写的数组位置与前一轮有交叠,每步都等上一步结果。这种串行链无法直接并行化。B、C 与距离为 0 对应,D 曲解了距离向量的含义。
Q6. 循环向量化(SIMD)的硬性前提是什么?
- A.迭代间无数据依赖,且访存尽量连续← 正确答案
- B.循环次数必须是 8 的倍数
- C.循环体内必须没有加法
- D.编译器必须开启内联
答案:A。向量化把多个迭代打包成一条指令执行,若迭代间有依赖则打包后结果错乱;内存连续有助于装载向量寄存器。循环次数不要求固定倍数(可处理尾部),循环体有无加法均无关,内联不是必要条件。
Q7. 哪种情况下,循环不变量外提会改变程序语义?
- A.被外提的计算可能触发异常,而原循环可能零次执行该路径← 正确答案
- B.不变量是整型常量
- C.循环体内只有一条语句
- D.循环变量可以被外提变量替换
答案:A。若循环可能不执行,则原程序根本不会执行该计算;外提后提前执行了一次,异常时机与副作用都变了,属于不安全的变换。整型常量外提无风险;循环体长短与外提语义无关;替换循环变量属于归纳变量消除。
Q8. 下列哪些是本文介绍的循环优化技术?
- A.循环不变量外提← 正确答案
- B.归纳变量消除← 正确答案
- C.死代码删除
- D.循环展开← 正确答案
答案:ABD。不变量外提、归纳变量消除、循环展开都是本文核心内容。死代码删除是通用优化,并非循环特化技术,因此在本文知识边界内不选 C。
Q9. 关于循环展开,下列说法正确的有?
- A.能减少循环条件判断次数← 正确答案
- B.可能增加寄存器压力← 正确答案
- C.一定会缩短运行时间
- D.需要处理迭代次数不被因子整除的尾部逻辑← 正确答案
答案:ABD。展开通过复制循环体减少回边跳转,因此 A 正确;多份循环体同时活跃会占用更多寄存器,B 正确;展开可能因缓存溢出而变慢,C 过于绝对;循环次数不是展开因子倍数时需要处理剩余迭代,D 正确。
Q10. 哪些因素会阻碍循环向量化?
- A.迭代之间存在循环携带依赖← 正确答案
- B.内存访问不连续(stride 较大)← 正确答案
- C.循环内完全没有数组操作
- D.目标 CPU 不支持所需 SIMD 指令集← 正确答案
答案:ABD。循环携带依赖使打包执行出错,A 当选;不连续访存无法高效装载向量寄存器,B 当选;没有数组操作时如涉及标量链也能向量化,C 不是阻碍;指令集不支持则无法生成向量指令,D 当选。