程序 80% 的时间花在 20% 的代码上,而这 20% 的代码几乎总是循环。循环优化是编译器优化的”皇冠上的明珠”——一个成功的循环优化可以将程序性能提升数倍甚至数十倍。
这一章的主角是循环优化——循环展开如何减少分支开销?向量化如何利用 SIMD 指令?循环不变量外提如何避免重复计算?强度消减如何将昂贵的运算替换为廉价的运算?
一、循环优化的重要性
1.1 为什么循环是优化的重点?
| 优化类型 | 典型加速比 | 适用频率 |
|---|---|---|
| 循环展开 | 1.2x-2x | 高 |
| 向量化 | 2x-8x | 中 |
| 循环不变量外提 | 1.1x-3x | 高 |
| 强度消减 | 1.1x-2x | 中 |
| 归纳变量简化 | 1.1x-1.5x | 中 |
1.2 循环的分析框架
编译器需要识别循环结构才能进行优化:
class LoopInfo: """循环信息"""
def __init__(self, header, body, exits, parent=None): self.header = header # 循环头基本块 self.body = body # 循环体基本块集合 self.exits = exits # 退出基本块集合 self.parent = parent # 外层循环 self.depth = 1 # 嵌套深度 if parent: self.depth = parent.depth + 1
def is_innermost(self): """是否是最内层循环""" return len(self.sub_loops) == 0
def identify_loops(cfg): """识别 CFG 中的自然循环""" loops = []
# 查找回边(header 支配 latch 的边) for block in cfg.blocks: for succ in block.successors: if dominates(succ, block, cfg): # 找到回边 block → succ loop_body = find_loop_body(succ, block, cfg) loop = LoopInfo(header=succ, body=loop_body, exits=[]) loops.append(loop)
return loops二、循环展开(Loop Unrolling)
2.1 基本原理
循环展开通过复制循环体减少循环控制开销(分支判断、计数器更新):
// 优化前:每次迭代都有分支和计数器更新for (int i = 0; i < 100; i++) { a[i] = b[i] + c[i];}
// 完全展开(100 次迭代全部展开)a[0] = b[0] + c[0];a[1] = b[1] + c[1];// ... 98 行省略 ...a[99] = b[99] + c[99];
// 部分展开(4 倍展开)for (int i = 0; i < 100; i += 4) { a[i] = b[i] + c[i]; a[i+1] = b[i+1] + c[i+1]; a[i+2] = b[i+2] + c[i+2]; a[i+3] = b[i+3] + c[i+3];}2.2 展开的收益与代价
| 展开因子 | 分支减少 | 代码增大 | 寄存器压力 | 推荐场景 |
|---|---|---|---|---|
| 2x | 50% | 2x | 低 | 通用 |
| 4x | 75% | 4x | 中 | 计算密集 |
| 8x | 87.5% | 8x | 高 | SIMD 前置 |
| 完全 | 100% | N*x | 极高 | 小循环(<10次) |
2.3 展开的实现
class LoopUnroller: """循环展开器"""
def unroll(self, loop, factor): """将循环展开 factor 倍""" if factor <= 1: return loop
# 检查迭代次数是否已知 trip_count = self._get_trip_count(loop) if trip_count is None: # 迭代次数未知,需要生成余数循环 return self._unroll_unknown_count(loop, factor)
if trip_count % factor != 0: # 迭代次数不是 factor 的倍数,需要处理余数 return self._unroll_with_remainder(loop, factor, trip_count)
# 完美展开 return self._unroll_perfect(loop, factor)
def _unroll_perfect(self, loop, factor): """完美展开(迭代次数是 factor 的倍数)""" new_body = [] for i in range(factor): # 复制循环体,替换归纳变量 for inst in loop.body_instructions: new_inst = self._rename_inst(inst, i) new_body.append(new_inst)
# 更新循环步长 loop.step *= factor loop.body_instructions = new_body return loop循环展开不是越多越好。过大的展开因子会导致指令缓存未命中增加,反而降低性能。LLVM 的 -funroll-loops 选项会根据循环体大小和目标处理器的缓存大小自动选择展开因子。
三、向量化(Vectorization)
3.1 SIMD 指令基础
SIMD(Single Instruction Multiple Data)一条指令同时处理多个数据:
| SIMD 扩展 | 位宽 | 32位元素数 | 平台 |
|---|---|---|---|
| SSE2 | 128 | 4 | x86 |
| AVX | 256 | 8 | x86 |
| AVX-512 | 512 | 16 | x86 |
| NEON | 128 | 4 | ARM |
| SVE | 128-2048 | 可变 | ARM |
3.2 循环向量化(Loop Vectorization)
循环向量化将标量循环转换为 SIMD 循环:
// 标量循环for (int i = 0; i < 1024; i++) { c[i] = a[i] + b[i];}
// 向量化后(AVX2,8 个 float 同时处理)for (int i = 0; i < 1024; i += 8) { __m256 va = _mm256_loadu_ps(&a[i]); // 加载 8 个 float __m256 vb = _mm256_loadu_ps(&b[i]); // 加载 8 个 float __m256 vc = _mm256_add_ps(va, vb); // 一次加 8 个 _mm256_storeu_ps(&c[i], vc); // 存储 8 个 float}3.3 向量化的条件
不是所有循环都可以向量化,编译器需要检查以下条件:
| 条件 | 说明 | 示例 |
|---|---|---|
| 无循环携带依赖 | 迭代之间无数据依赖 | a[i] = a[i-1] + 1 不可向量化 |
| 连续内存访问 | 数组元素在内存中连续 | a[i*stride] stride=1 最佳 |
| 循环次数已知 | 至少在入口可计算 | while(cond) 难以向量化 |
| 无函数调用 | 或调用是 SIMD 安全的 | printf 不可向量化 |
| 无分支 | 或分支可转换为 mask | 简单 if 可用 mask 向量化 |
3.4 依赖分析
class DependencyAnalyzer: """循环依赖分析器"""
def analyze(self, loop): """分析循环中的依赖关系""" deps = []
for i, inst1 in enumerate(loop.body): for j, inst2 in enumerate(loop.body): if i >= j: continue
# 检查是否有依赖 dep = self._check_dependency(inst1, inst2) if dep: deps.append(dep)
return deps
def _check_dependency(self, inst1, inst2): """检查两条指令之间的依赖""" # 写后读(WAR)— 反依赖 if inst1.is_read and inst2.is_write: if self._same_array(inst1, inst2): return Dependency('WAR', inst1, inst2)
# 读后写(RAW)— 真依赖 if inst1.is_write and inst2.is_read: if self._same_array(inst1, inst2): return Dependency('RAW', inst1, inst2)
# 写后写(WAW)— 输出依赖 if inst1.is_write and inst2.is_write: if self._same_array(inst1, inst2): return Dependency('WAW', inst1, inst2)
return None3.5 SLP 向量化
SLP(Superword-Level Parallelism)向量化不同于循环向量化——它在同一迭代内寻找可以打包为 SIMD 的操作:
// SLP 向量化示例struct Point { float x, y, z, w; };
void transform(Point* p, float s) { p->x = p->x * s; // 这四个乘法 p->y = p->y * s; // 可以打包为 p->z = p->z * s; // 一条 SIMD 乘法 p->w = p->w * s; // 指令}| 向量化类型 | 目标 | 适用场景 |
|---|---|---|
| 循环向量化 | 跨迭代并行 | 大数组遍历 |
| SLP 向量化 | 迭代内并行 | 结构体操作、解相关计算 |
四、循环不变量外提(LICM)
4.1 基本原理
循环不变量是在循环中每次迭代结果都相同的计算——可以提到循环外面:
// 优化前for (int i = 0; i < n; i++) { a[i] = b[i] * (x + y); // x + y 是循环不变量}
// 优化后int temp = x + y; // 外提到循环前for (int i = 0; i < n; i++) { a[i] = b[i] * temp;}4.2 LICM 的实现
class LICM: """循环不变量外提"""
def hoist(self, loop): """将循环不变量外提到循环前置节点""" preheader = self._ensure_preheader(loop)
changed = True while changed: changed = False for block in loop.body: new_insts = [] for inst in block.instructions: if self._is_loop_invariant(inst, loop): # 外提到 preheader preheader.instructions.append(inst) changed = True else: new_insts.append(inst) block.instructions = new_insts
return loop
def _is_loop_invariant(self, inst, loop): """判断指令是否是循环不变量""" # 有副作用不是不变量 if inst.op in ('store', 'call', 'load'): return False
# 终止指令不是不变量 if inst.op in ('br', 'ret', 'phi'): return False
# 所有操作数都是循环不变量 for operand in inst.operands: if isinstance(operand, str): # 检查操作数是否在循环外定义 def_block = self._get_definition_block(operand) if def_block in loop.body: return False
return True
def _ensure_preheader(self, loop): """确保循环有前置节点""" if loop.preheader: return loop.preheader
# 创建前置节点 preheader = BasicBlock(f"{loop.header.label}.preheader") # 调整 CFG 边 # ... return preheader4.3 LICM 的安全条件
外提必须保证不改变程序语义:
| 条件 | 说明 | 示例 |
|---|---|---|
| 定义支配使用 | 外提后的定义必须支配所有使用 | 循环内定义的变量 |
| 无副作用 | load 可能有异常,不能随意外提 | *ptr 可能段错误 |
| 不改变异常行为 | 外提不能改变异常抛出的时机 | 除零异常 |
| 循环可能不执行 | 需要判断循环是否至少执行一次 | for(i=0;i<n;i++) n 可能是 0 |
对于 load 指令的外提,编译器需要证明:1) 地址在循环中不变;2) 没有其他 store 指令可能修改该地址的值(别名分析);3) 循环至少执行一次。如果无法证明,编译器会保守地不外提。
五、强度消减(Strength Reduction)
5.1 基本原理
将昂贵的运算替换为廉价的运算:
| 替换 | 代价变化 | 示例 |
|---|---|---|
| 乘法 → 加法 | O(1) → O(1) 但更快 | i * 4 → j += 4 |
| 乘法 → 移位 | 乘法器 → 移位器 | i * 8 → i << 3 |
| 除法 → 移位 | 极慢 → 极快 | i / 4 → i >> 2 |
| 取模 → 位与 | 极慢 → 极快 | i % 8 → i & 7 |
| 乘方 → 乘法 | 循环 → 单次 | x * x 代替 pow(x, 2) |
5.2 归纳变量的强度消减
// 优化前:数组索引使用乘法for (int i = 0; i < n; i++) { a[i * 4] = 0; // 每次迭代计算 i * 4}
// 强度消减后:用加法替代乘法for (int i = 0, j = 0; i < n; i++, j += 4) { a[j] = 0; // j 每次加 4,无需乘法}5.3 强度消减的实现
class StrengthReducer: """强度消减器"""
def reduce(self, loop): """对循环中的归纳变量进行强度消减""" # 识别归纳变量 ivs = self._find_induction_vars(loop)
for iv in ivs: # 查找 iv * constant 模式 for user in iv.users: if user.op == 'mul' and user.operands[1].is_constant: # 替换为新的归纳变量 new_iv = self._create_derived_iv( iv, user.operands[1].value, loop ) user.replace_with(new_iv)
return loop
def _find_induction_vars(self, loop): """识别归纳变量""" ivs = [] for block in loop.body: for inst in block.instructions: if inst.op == 'phi': # phi 节点可能是归纳变量 # 检查递增模式:iv = iv + step if self._is_linear_induction(inst, loop): ivs.append(inst) return ivs六、归纳变量简化(IndVar Simplification)
6.1 基本原理
归纳变量简化将不同的归纳变量统一为单一的标准归纳变量:
// 优化前:三个归纳变量for (int i = 0; i < n; i++) { int j = i * 4; // 派生归纳变量 int k = i * 4 + 1; // 派生归纳变量 a[j] = 0; b[k] = 1;}
// 归纳变量简化后for (int i = 0; i < n * 4; i += 4) { a[i] = 0; b[i + 1] = 1;}6.2 循环优化的协同
优化的典型顺序:先外提不变量(LICM),再简化归纳变量,然后强度消减,展开循环,最后向量化。
七、循环优化的实际效果
7.1 一个完整的优化示例
// 原始代码void add_arrays(float* a, float* b, float* c, int n, float scale) { for (int i = 0; i < n; i++) { c[i] = (a[i] + b[i]) * scale; }}# 查看不同优化级别的汇编输出clang -O0 -S add_arrays.c # 无优化clang -O1 -S add_arrays.c # 基本优化clang -O2 -S add_arrays.c # 标准优化(包含向量化)clang -O3 -S add_arrays.c # 激进优化(更多展开)7.2 优化级别对循环的影响
| 优化级别 | LICM | 强度消减 | 展开 | 向量化 | 典型加速 |
|---|---|---|---|---|---|
| -O0 | 否 | 否 | 否 | 否 | 1x |
| -O1 | 是 | 是 | 否 | 否 | 1.5-2x |
| -O2 | 是 | 是 | 是 | 是 | 3-8x |
| -O3 | 是 | 是 | 激进 | 是 | 3-10x |
7.3 阻止向量化的常见原因
// 1. 循环携带依赖for (int i = 1; i < n; i++) { a[i] = a[i-1] + 1; // 依赖前一次迭代}
// 2. 未知指针别名void add(float* a, float* b, float* c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; // 如果 c 和 a/b 重叠呢? }}
// 3. 循环内分支for (int i = 0; i < n; i++) { if (a[i] > 0) { // 分支难以向量化 b[i] = a[i]; }}
// 解决方案void add_restrict(float* __restrict__ a, float* __restrict__ b, float* __restrict__ c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; // restrict 保证不重叠 }}__restrict__ 关键字告诉编译器指针不会重叠,这是帮助编译器向量化的最有效方法之一。在 C99 中使用 restrict,在 C++ 中使用 __restrict__(编译器扩展)。
八、动手实践
8.1 实验一:观察循环向量化
cat > vec_test.c << 'EOF'void add(float* a, float* b, float* c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; }}EOF
# 查看向量化报告clang -O2 -Rpass=loop-vectorize vec_test.c -c 2>&1# 输出:vec_test.c:3:5: remark: vectorized loop (vectorization width: 8, interleaved count: 4)
# 查看汇编中的 SIMD 指令clang -O2 -S vec_test.c -o vec_test.sgrep -E "(vmov|vadd|vld|vst)" vec_test.s8.2 实验二:对比 restrict 的影响
cat > restrict_test.c << 'EOF'// 无 restrictvoid add1(float* a, float* b, float* c, int n) { for (int i = 0; i < n; i++) c[i] = a[i] + b[i];}
// 有 restrictvoid add2(float* __restrict__ a, float* __restrict__ b, float* __restrict__ c, int n) { for (int i = 0; i < n; i++) c[i] = a[i] + b[i];}EOF
clang -O2 -S restrict_test.c -o restrict_test.sdiff <(grep -A5 "add1:" restrict_test.s) <(grep -A5 "add2:" restrict_test.s)8.3 实验三:手动向量化 vs 自动向量化
#include <immintrin.h>
// 自动向量化void auto_vec(float* a, float* b, float* c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; }}
// 手动向量化void manual_vec(float* a, float* b, float* c, int n) { int i = 0; for (; i + 7 < n; i += 8) { __m256 va = _mm256_loadu_ps(&a[i]); __m256 vb = _mm256_loadu_ps(&b[i]); __m256 vc = _mm256_add_ps(va, vb); _mm256_storeu_ps(&c[i], vc); } for (; i < n; i++) { c[i] = a[i] + b[i]; }}九、本章小结
在上一章中,常量折叠、死代码消除和公共子表达式消除等基础优化展示了”不改变语义的前提下让程序更快”的思路。但这些优化对循环的效果有限——而程序 80% 的时间恰恰花在循环中。要真正释放性能,编译器必须深入循环结构做变换:展开、向量化、不变量外提。
| 概念 | 要点 |
|---|---|
| 循环展开 | 复制循环体减少分支开销,注意代码体积和寄存器压力 |
| 向量化 | SIMD 指令同时处理多个数据,需要无循环携带依赖 |
| LICM | 循环不变量外提到循环前置节点,注意副作用和安全条件 |
| 强度消减 | 乘法→加法/移位,除法→移位,取模→位与 |
| 归纳变量简化 | 统一不同归纳变量为标准形式 |
| 依赖分析 | RAW/WAR/WAW 三种依赖,RAW 是真依赖 |
| SLP 向量化 | 迭代内的 SIMD 并行 |
| restrict | 告诉编译器指针不重叠,帮助向量化 |
用了整章的篇幅拆解循环优化。下一章进入 寄存器分配,看看编译器如何在有限的物理寄存器中安排无限多的虚拟寄存器。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






