循环优化与依赖分析

程序里绝大部分时间花在循环上,因此循环是优化器的主战场。本文讲解循环变换的分类与合法性、依赖距离向量与方向向量、依赖测试与多面体模型,以及向量化真正需要的前置条件。

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 都是这个路线的实现。

工具依托输入输出
PollyLLVMLLVM 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];

向量化器要依次确认四件事:

  1. 无循环携带依赖(或依赖距离足够远,可以分段处理)。
  2. 访存对齐与连续。跨步访问需要 gather/scatter,多数平台代价高。
  3. 无控制流分歧。循环体内有条件分支时要先做 if 转换或谓词化。
  4. 无函数调用或可内联。调用会破坏向量化的连续性。
// 用 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 这些注解的本质,都是把程序员已知而编译器无法证明的事实告诉编译器。下一篇我们讨论异常处理编译,那里有另一个「运行期与编译期分工」的经典设计:零成本异常。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类