1. 循环为何是优化的主战场
一句话总结: 循环体内的代码被执行成千上万次,因此针对循环的一处变换能带来成倍的收益,而它的合法性完全取决于依赖分析能证明什么。
优化的收益正比于被优化代码的执行频次。一个函数的调用可能只有几次,而它内部的循环可能执行上百万次。这就是为什么工业编译器把中端优化的一半以上精力放在循环上:循环嵌套是程序里唯一具有明确重复结构、可以被系统性重排的代码形态。
// 一段普通的矩阵乘:三重循环,最内层执行 n^3 次
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) {
double acc = 0.0;
for (int k = 0; k < n; k++)
acc += A[i][k] * B[k][j];
C[i][j] = acc;
}
这段代码有几个众所周知的低效点:B[k][j] 的访问跨步为 n,缓存命中率极差;内层循环的每次迭代都做一次乘加,没有利用 SIMD;循环开销(比较与自增)占了相当比例。
但不能随便改。如果交换 j 与 k 两层循环,语义会变吗?答案取决于 A、B、C 之间是否存在别名,以及循环体是否存在跨迭代依赖。整个循环优化的理论体系,就是围绕「如何证明一次变换是合法的」建立起来的。
| 变换 | 目的 | 合法性依据 |
|---|---|---|
| 循环交换 | 改善访存局部性 | 依赖方向允许重排 |
| 循环分块 | 提高缓存复用 | 无跨块的反向依赖 |
| 循环展开 | 减少开销、暴露 ILP | 无依赖或依赖距离已知 |
| 循环融合 | 减少遍历次数 | 两循环迭代空间一致 |
| 循环分裂 | 分离不同类型的语句 | 语句间无交叉依赖 |
| 循环倾斜 | 暴露并行性 | 依赖方向可被旋转 |
2. 循环变换的分类
一句话总结: 循环变换可以按「改变什么」分成三类:改变迭代顺序、改变迭代划分、改变循环结构,每一类都有对应的合法性条件。
2.1 迭代顺序与划分
一句话总结: 交换、反转、倾斜改变迭代的执行顺序;分块与展开改变迭代的划分方式,前者为缓存服务,后者为指令级并行服务。
// 循环交换:把 j 与 k 互换,让最内层访问连续内存
for (int i = 0; i < n; i++)
for (int k = 0; k < n; k++) {
double a = A[i][k];
for (int j = 0; j < n; j++)
C[i][j] += a * B[k][j]; // B 按行访问,连续
}
交换后 C[i][j] += ... 需要对 C 做读改写,如果原本 C 是 A 与 B 的别名,语义就会改变。因此循环交换的前提是依赖分析确认 C 与 A、B 无别名,或者依赖方向允许。
// 循环倾斜:把迭代空间沿对角线切割,让依赖方向变成水平,从而可向量化
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
A[i][j] = A[i-1][j+1] + 1; // 依赖方向 (-1, +1),斜向
// 倾斜后:i' = i + j,依赖变成 (-1, 0),可以按 i' 并行
for (int i2 = 0; i2 < 2 * n - 1; i2++)
for (int j = max(0, i2 - n + 1); j <= min(n - 1, i2); j++)
A[i2-j][j] = A[i2-j-1][j+1] + 1;
2.2 循环展开与分块
一句话总结: 展开复制循环体以减少分支与自增开销并暴露指令级并行;分块把迭代空间切成小块,让每个块的工作集装进缓存。
// 循环展开:因子 4,减少 3/4 的循环控制开销
for (int i = 0; i + 3 < n; i += 4) {
s += a[i]; s += a[i+1]; s += a[i+2]; s += a[i+3];
}
for (; i < n; i++) s += a[i]; // 余数循环
// 循环分块:块大小 B 让 A 的一块与 B 的一块能同时在缓存里
for (int ii = 0; ii < n; ii += B)
for (int kk = 0; kk < n; kk += B)
for (int jj = 0; jj < n; jj += B)
for (int i = ii; i < ii + B; i++)
for (int k = kk; k < kk + B; k++) {
double a = A[i][k];
for (int j = jj; j < jj + B; j++)
C[i][j] += a * B[k][j];
}
分块的理论收益可以用工作集大小估算。若缓存容量为 M 字节、元素宽 w 字节,则块大小 B 应满足 3 * B * B * w <= M(矩阵乘需要同时驻留三个块)。超出这个条件,分块不但无益,还会因额外的索引计算而变慢。
| 变换 | 典型收益 | 主要代价 |
|---|---|---|
| 展开因子 4 | 控制开销降 75% | 代码体积增大,寄存器压力上升 |
| 分块 B=64 | 缓存命中率显著提升 | 索引计算变复杂,边界处理繁琐 |
| 交换 | 访存模式从跨步变连续 | 需证明无别名 |
| 倾斜 | 暴露并行性 | 迭代空间变成非矩形,边界判断多 |
3. 依赖分析基础
一句话总结: 依赖分析要回答的问题是:两次访问同一内存位置、且至少一次是写,它们的执行顺序能否交换。
3.1 依赖的种类
一句话总结: 按访问性质分,依赖有流依赖、反依赖、输出依赖与输入依赖四种;前三种限制重排,第四种不影响正确性但影响并行性判断。
// 同一个循环里的四类依赖
for (int i = 1; i < n; i++) {
a[i] = a[i-1] + 1; // 流依赖(RAW):先读后写,方向 i-1 -> i
b[i-1] = b[i] * 2; // 反依赖(WAR):先写后读
c[i] = c[i] + 1; // 输出依赖(WAW):两次写
d[i] = d[i-1]; // 输入依赖(RAR):两次读,不影响重排
}
| 类型 | 形式 | 是否限制重排 | 是否限制并行 |
|---|---|---|---|
| 流依赖 RAW | 先写后读 | 是 | 是 |
| 反依赖 WAR | 先读后写 | 是 | 是(可私有化消除) |
| 输出依赖 WAW | 先写后写 | 是 | 是(可私有化消除) |
| 输入依赖 RAR | 先读后读 | 否 | 否 |
一个重要的区分是循环无关依赖与循环携带依赖。前者是同一轮迭代内的依赖,后者跨迭代。只有循环携带依赖才会阻止并行化:
for (int i = 0; i < n; i++) {
int t = a[i]; // 循环无关:只在本轮内
b[i] = t + 1;
c[i] = d[i-1] + 1; // 循环携带:跨迭代,阻碍并行
}
3.2 距离向量与方向向量
一句话总结: 距离向量记录依赖在每一层循环上跨越的迭代数,方向向量只记录符号;前者更精确,后者更通用。
// 二维循环嵌套:A[i][j] 写,A[i-1][j+1] 读
for (int i = 1; i < n; i++)
for (int j = 0; j < n - 1; j++)
A[i][j] = A[i-1][j+1] + 1;
依赖发生的位置是 (i, j) 与 (i-1, j+1),因此距离向量是 (1, -1),方向向量是 (<, >)。约定:距离为正表示依赖从较早迭代指向较晚迭代(源在前),负号表示反向。
方向向量的含义直接对应变换的合法性:
| 方向向量 | 含义 | 可否并行最内层 |
|---|---|---|
(=, <) | 最内层正向携带依赖 | 否 |
(<, =) | 外层携带,内层无关 | 可以,内层可并行 |
(<, >) | 斜向依赖 | 否,但可倾斜后并行 |
(=, =) | 同一次迭代 | 是(循环无关) |
# 用方向向量判断最内层能否并行
def innermost_parallel(dirs):
for d in dirs:
if d[-1] != "=": # 最内层存在携带依赖
return False, f"最内层方向 {d[-1]},阻碍并行"
return True, "最内层无携带依赖,可并行或向量化"
距离向量比方向向量强:若某层距离恒为 0,则该层完全无依赖;方向向量只能给出「可能」。因此编译器先算距离,算不出时才退化为方向向量。
4. 依赖测试
一句话总结: 判断两个带下标表达式的访问是否可能冲突,需要解一个丢番图方程的可满足性问题,精确求解代价高,工程上多用保守的近似测试。
// 判断 A[2*i] 与 A[2*j+1] 是否可能访问同一位置:解 2i = 2j + 1
// 左边恒为偶数,右边恒为奇数,无解 —— 精确判定为无依赖
| 测试方法 | 精确度 | 复杂度 | 适用场景 |
|---|---|---|---|
| GCD 测试 | 低 | O(1) | 快速排除不可能冲突 |
| 极值测试 | 中 | O(1) | 下标范围已知时 |
| Banerjee 测试 | 中高 | O(d) | 通用近似,最常用 |
| Omega 测试 | 高 | 指数最坏 | 需精确结果时 |
| 多面体求解 | 最高 | 指数最坏 | 小维度、需精确 |
# GCD 测试:若 gcd(a, b) 不能整除 (c2 - c1),则无解
from math import gcd
def gcd_test(a, b, c1, c2):
"""访问 a*i + c1 与 b*j + c2 是否可能相交"""
if a == 0 and b == 0:
return c1 == c2 # 都是常量
g = gcd(abs(a), abs(b))
return (c2 - c1) % g == 0 # 有解才有依赖
Banerjee 测试的思路更完整:它把依赖方程按每层循环拆开,分别计算该层在合法迭代范围内的最小与最大值,再判断能否取到 0。它的判定是必要非充分:返回「无依赖」时可信,返回「有依赖」时可能保守。
5. 多面体模型
一句话总结: 多面体模型把循环嵌套的迭代空间表示成整数多面体,把变换表示成仿射映射,于是「变换是否合法」变成一个可以精确求解的数学问题。
// 源循环
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
A[i][j] = A[i][j-1] + A[i-1][j];
// 迭代空间(多面体):
// { (i, j) : 0 <= i < n, 0 <= j < n }
// 依赖:
// (i, j) -> (i, j-1) 距离 (0, 1)
// (i, j) -> (i-1, j) 距离 (1, 0)
变换用调度函数描述。比如循环交换对应的调度是 (i, j) -> (j, i),倾斜对应的调度是 (i, j) -> (i + j, j)。合法性条件是:对每一条依赖边 (s, t),调度后 s 的字典序必须仍然先于 t。
# 检查一个调度对一条依赖是否合法
def legal(schedule, src, dst):
"""src -> dst 是依赖,要求 schedule(src) <lex schedule(dst)"""
a, b = schedule(*src), schedule(*dst)
return a < b # 元组的字典序比较
# 循环交换调度:依赖 (0,1) 是否合法?
# src=(1,0) -> sched=(0,1); dst=(1,-1)? 实际按原始坐标比较:
# 依赖 src=(i,j) -> dst=(i,j-1),sched 后 (j,i) -> (j-1,i),合法
print(legal(lambda i, j: (j, i), (1, 1), (1, 0))) # True
多面体的威力在于它能自动搜索最优调度:给定依赖约束,求解使并行度最大、局部性最好的仿射调度。Polly(LLVM 的多面体优化器)与 Pluto 都是这个路线的实现。
| 工具 | 依托 | 输入 | 输出 |
|---|---|---|---|
| Polly | LLVM | LLVM IR 中的循环 | 变换后的 IR |
| Pluto | 独立工具 | 源级循环加注解 | 变换后的 C 代码 |
| ISL | 库 | 多面体与约束 | 求解结果与调度 |
| Tiramisu | 库 | 多面体 IR | 多后端代码 |
6. 向量化的前置条件
一句话总结: 向量化不是「把循环转成 SIMD 指令」,而是「证明循环体各迭代互不干扰且访存连续」,这个证明依赖前面所有的依赖分析。
// 可向量化:无携带依赖,访存连续
for (int i = 0; i < n; i++)
c[i] = a[i] + b[i];
// 不可向量化:存在距离为 1 的流依赖
for (int i = 1; i < n; i++)
a[i] = a[i-1] + b[i];
向量化器要依次确认四件事:
- 无循环携带依赖(或依赖距离足够远,可以分段处理)。
- 访存对齐与连续。跨步访问需要 gather/scatter,多数平台代价高。
- 无控制流分歧。循环体内有条件分支时要先做 if 转换或谓词化。
- 无函数调用或可内联。调用会破坏向量化的连续性。
// 用 restrict 与 #pragma omp simd 帮助向量化器
void add(const double *restrict a, const double *restrict b, double *restrict c, int n) {
#pragma omp simd
for (int i = 0; i < n; i++) c[i] = a[i] + b[i];
}
# 让编译器报告向量化决策
clang -O3 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize add.c -c
gcc -O3 -fopt-info-vec-all add.c -c
报告里最常见的未向量化理由是 「possible dependence」——不是真的有依赖,而是编译器无法证明没有。加 restrict 或 #pragma ivdep(在人工确认安全时)可以解除这个障碍。
7. 实现要点与陷阱
一句话总结: 循环优化最容易出的问题不是算法错,而是假设错:别名、整数溢出、浮点结合律与别名分析的不精确都会让「看起来正确」的变换产生错误结果。
| 陷阱 | 表现 | 应对 |
|---|---|---|
| 忽略潜在别名 | 交换后结果错 | 依赖分析保守处理,或要求 restrict |
| 假设浮点可结合 | 结果与源不同 | 需 -ffast-math 或显式允许 |
| 整数溢出下变换 | 包裹语义被破坏 | 用 nsw/nuw 标记,未标记则保守 |
| 展开后余数循环错 | 越界访问 | 严格生成余数循环并核对边界 |
| 分块边界处理不当 | 最后一轮块越界 | 用 min 收窄上界 |
| 多面体模型编译时间爆炸 | 编译变慢数十倍 | 限制维度与约束数量 |
// 陷阱:这个循环看似可向量化,但 a 与 c 可能别名
for (int i = 0; i < n; i++) c[i] = a[i] + 1;
// 若 c == a + 1(重叠),向量化后语义改变
// 解决:加 restrict,或用运行期别名检查
// 运行期别名检查:两个版本,运行时分派
if (c + n <= a || a + n <= c) {
vectorized_loop(c, a, n); // 无重叠,走向量版本
} else {
scalar_loop(c, a, n); // 可能重叠,走标量版本
}
这个「版本化加运行期分派」的模式在工业编译器里非常常见:它把编译期的不可判定问题推迟到运行期,用一次廉价的地址比较换取向量化的机会。理解了循环优化与依赖分析,也就理解了为什么「同样的源码在不同编译器上性能差三倍」——差异往往不在后端指令选择,而在前端与中端能否证明某次变换是安全的。下一篇我们会转向另一个同样依赖精确语义的问题:异常处理在编译期如何被翻译成表驱动的栈展开。
8. 总结
| 环节 | 要点 |
|---|---|
| 优化重心 | 循环体执行频次最高,一处变换收益成倍 |
| 变换分类 | 改顺序(交换/倾斜)、改划分(分块/展开)、改结构(融合/分裂) |
| 依赖类型 | RAW 与 WAR 与 WAW 限制重排,RAR 不影响 |
| 携带性 | 只有循环携带依赖阻碍并行化 |
| 距离向量 | 记录每层跨越的迭代数,比方向向量精确 |
| 依赖测试 | GCD 快速排除,Banerjee 通用,多面体精确但昂贵 |
| 多面体模型 | 迭代空间为多面体,变换为仿射调度,合法性为字典序 |
| 向量化条件 | 无携带依赖、访存连续、无分歧、可内联 |
| 保守代价 | 证明不了就不优化,restrict 与版本化是常见出路 |
循环优化的全部难度都集中在一个词上:证明。变换本身都很简单——交换两层循环、把迭代空间切成块,几行代码就能实现;难的是在编译期确定这样做不会改变程序语义。而编译期能证明的东西受限于别名分析、整数语义与浮点模型这三道边界。这解释了为什么高级优化总是需要程序员的帮助:restrict、#pragma omp simd、ivdep 这些注解的本质,都是把程序员已知而编译器无法证明的事实告诉编译器。下一篇我们讨论异常处理编译,那里有另一个「运行期与编译期分工」的经典设计:零成本异常。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。