「窥孔优化与超优化」

窥孔优化用局部改写规则清理指令序列里的冗余,超优化则把改写规则的发现交给搜索。本文讲解滑动窗口匹配、代价模型、超级优化器的枚举与 E-graph 搜索,以及等价性验证如何保证改写不改变语义。

1. 窥孔优化:局部改写

一句话总结: 窥孔优化用一个小窗口滑过指令序列,窗口内的模式一旦匹配某条改写规则就替换成更优的等价序列,是编译器里最简单也最持久有效的优化之一。

窥孔优化(peephole optimization)的名字来自它的工作方式:像通过一个小孔看代码,每次只看相邻的几条指令。它不构建全局分析,不做复杂的图变换,只是反复地问一个问题:这窗口里的指令,能不能用更少的指令、更快的指令替换掉?

// 窥孔优化最经典的几个例子
// 1) 冗余加载:mov [rbp-8],rax ; mov rax,[rbp-8]  ->  mov [rbp-8],rax
// 2) 强度削减:imul rax, 8                        ->  shl rax, 3
// 3) 常量折叠:mov rax,4 ; add rax,6              ->  mov rax, 10
// 4) 空跳转:  jmp .Lnext ; .Lnext: ...           ->  .Lnext: ...

窥孔优化的魅力在于它的局部性:不需要任何全局信息,只需要一个模式匹配引擎与一张规则表。这让它极易实现,也让它在整个编译流水线里可以反复运行——在 IR 上跑一遍,在机器码上再跑一遍,每次都能清掉上一轮优化留下的新冗余。

1.1 滑动窗口与模式匹配

一句话总结: 实现上通常用固定大小的窗口(2 到 5 条指令)滑过指令序列,用模式语言描述左右两侧,匹配成功就替换,替换后回退窗口以捕获连锁机会。

最朴素的实现是窗口滑动:维护一个大小固定的窗口,从序列头部开始逐条前移。每次移动后尝试所有规则;一旦某条规则命中,执行替换,然后把窗口回退若干位置——因为替换产生的新指令可能与前面的指令构成新的匹配机会。

# 一个极简的窥孔优化器骨架
RULES = [
    # (模式, 替换):None 为通配符
    ((("mov", "mem", "reg"), ("mov", "reg", "mem")), (("mov", "mem", "reg"),)),
    ((("imul", "reg", ("const", 8)),), (("shl", "reg", 3),)),
    ((("jmp", "L"), ("label", "L")), ()),                  # 跳转到下一条
]

def match(pattern, window):
    """长度一致且逐项匹配(含嵌套元组)则返回 True"""
    if len(pattern) != len(window):
        return False
    for p, w in zip(pattern, window):
        if p is None:                       # None 通配任意一条
            continue
        if isinstance(p, tuple):
            if not isinstance(w, tuple) or len(p) != len(w):
                return False
            if any(pi is not None and pi != wi for pi, wi in zip(p, w)):
                return False
        elif p != w:
            return False
    return True

def peephole(instrs, max_window=3):
    changed = True
    while changed:                          # 迭代到不动点
        changed = False
        for i in range(len(instrs)):
            for size in range(max_window, 1, -1):   # 先试长窗口
                win = instrs[i:i + size]
                if len(win) < size:
                    continue
                for pat, rep in RULES:
                    if match(pat, win):
                        instrs[i:i + size] = list(rep)
                        changed = True
                        break
                if changed:
                    break
    return instrs

这个骨架展示了窥孔优化的三个工程要点:窗口大小(太大则匹配爆炸,太小则错过机会,实践中 2 到 5 条)、迭代到不动点(一次改写可能解锁新机会)、规则优先级(更具体的规则优先)。

1.2 常见窥孔规则

一句话总结: 真实编译器的窥孔规则表有几百条,覆盖冗余访存、代数简化、强度削减、分支优化与指令合并五大类。

类别典型规则收益
冗余访存存后即取 → 删取省一次访存
常量传播两条常量运算 → 一条省一条指令
强度削减乘 2 的幂 → 移位延迟从 3 降到 1
代数简化加 0、乘 1、减自身 → 消除省指令,暴露后续优化
分支优化跳转到下一条 → 删除省取指与预测槽
指令合并两条相邻访存 → 一条宽访存减少访存次数
// 强度削减与指令合并的具体效果
// 改前(x86-64,-O1)
//   lea  rax, [rdi + rdi*8]     ; rax = 9 * x
//   mov  ecx, [rsi]             ; 载入两个 32 位
//   mov  edx, [rsi+4]
// 改后(窥孔 + 合并)
//   lea  rax, [rdi + rdi*8]
//   mov  rcx, [rsi]             ; 一条 64 位载入代替两条 32 位
long f(long x, const int *p) {
    long a = x * 9;
    long b = p[0];
    long c = p[1];
    return a + b + c;
}

窥孔规则的正确性通常靠人工证明 + 回归测试保证。每条规则在加入时都要论证:对任意输入,替换前后的结果相同、副作用顺序不变、异常行为一致。这条纪律在规则只有几十条时可行,到了几百条就难以维持——这正是超优化要解决的问题。

2. 从局部到全局:代价模型

一句话总结: 局部改写需要判断改前改后谁更优,这要求一个能给出指令延迟、吞吐、体积的代价模型,否则可能把代码改慢。

窥孔优化看起来只是「删指令」,但删掉的指令不一定更快。一条 mov 在寄存器之间移动是零延迟(被重命名消除),一次内存加载可能是 4 个周期也可能是 200 个周期(缓存缺失)。要做出正确决策,需要一个代价模型(cost model)。

2.1 指令代价与收益

一句话总结: 代价模型把每条指令映射成延迟、吞吐倒数、代码字节数的三元组,再按目标函数加权求和,权重取决于优化目标是速度还是体积。

# 一个简化但可用的代价模型
# 每条指令 -> (latency, reciprocal_throughput, size_bytes)
COST_TABLE = {
    "mov_rr":   (0, 0.25, 3),    # 寄存器间移动,被重命名消除
    "mov_rm":   (4, 0.5,  4),    # 从内存加载(L1 命中)
    "mov_mr":   (3, 1.0,  4),    # 存到内存
    "add_rr":   (1, 0.25, 3),
    "imul_rr":  (3, 1.0,  4),
    "shl_ri":   (1, 0.5,  4),
    "div_rr":   (20, 6.0, 3),    # 除法很贵
    "jmp":      (1, 1.0,  2),
    "jcc":      (1, 0.5,  2),
    "call":     (2, 1.0,  5),
}

def cost(seq, mode="speed", size_weight=0.05):
    lat, thr, sz = 0.0, 0.0, 0
    for op in seq:
        l, t, s = COST_TABLE[op]
        lat += l; thr += t; sz += s
    if mode == "speed":
        return thr + size_weight * sz   # 吞吐 + 体积惩罚
    return sz                           # 体积优先:只关心字节数

这个模型显然粗糙:它忽略了乱序执行的并行度、忽略了缓存层次、忽略了端口争用。但粗糙的模型胜过没有模型——只要它在绝大多数情况下给出正确的相对顺序,窥孔优化就能做出比「凭直觉删指令」更好的决策。

更精确的做法是给每条指令标注延迟与发射端口,用列表调度(list scheduling)模拟关键路径长度,同时对多个端口做资源约束求解。这类模型在 LLVM 的 TargetTransformInfo 与 SchedModel 里有完整实现,规则表则由各后端在 TargetInstrInfo 里手工描述。

# 用 llvm-mca 分析一段机器码的代价
cat > hot.s <<'EOF'
    movq   (%rdi), %rax
    imulq  $9, %rax, %rax
    addq   %rsi, %rax
    movq   %rax, (%rdx)
EOF
llvm-mca -mcpu=skylake -iterations=100 hot.s
# 输出里会给出:Block RThroughput、每次迭代的周期数、各端口压力

3. 超级优化器的思想

一句话总结: 超级优化器不再由人写改写规则,而是让程序自己在庞大的等价程序空间里搜索,找出最短或最快的实现。

超级优化(superoptimization)的出发点是一个反问:为什么改写规则要由人手工总结?如果给计算机一个指令集、一个代价函数、一个目标序列,它能不能自己搜出一个更短的等价序列?

这个想法最早由 Massalin 在 1987 年提出,他用暴力枚举为短序列寻找最优实现,找到了人类从未写过的指令序列。经典例子是「符号函数」的最短实现:

// 符号函数:返回 x 的符号(-1、0、1)
int sign_naive(int x) {              // 教科书实现:分支 + 3 条指令
    if (x > 0) return 1;
    if (x < 0) return -1;
    return 0;
}

// 超级优化器找到的无分支实现(x86-64,无跳转)
//   mov edx,1 ; mov eax,0 ; test edi,edi ; cmovg eax,edx
// 消除分支预测失败,随机输入下比教科书版本快数倍
int sign_branchless(int x) {
    return (x > 0) - (x < 0);   // 编译器通常也能生成无分支版本
}

超级优化器真正有价值的地方在于发现:它找到了人不会想到的指令组合,比如用 lea 同时做乘加、用 xor 清零并打断依赖链、用 setcc 加 sbb 实现条件取反。

3.1 枚举搜索与 E-graph

一句话总结: 早期超级优化器用 BFS 枚举所有指令序列并逐个验证等价性,规模上无法超过 6 到 8 条指令;现代方案用 E-graph 把等价类压缩成图,一次搜索覆盖指数级多的程序。

暴力枚举的复杂度是 O(N^K),其中 N 是可用指令数(约几十条),K 是序列长度。长度 4 时是百万级,长度 8 时是万亿级,无法接受。于是出现了两条改进路线:

第一条是剪枝枚举。用已知的等价类去重(不同序列若语义相同只保留代价最小的),用类型与寄存器约束提前排除不可能的组合,把搜索空间压到可行范围。这条路线在 STOKE 这类随机搜索优化器里被推到了极致——它不做穷举,而是用 MCMC(马尔可夫链蒙特卡洛)在程序空间里随机游走,以代价函数为能量,接受使代价下降的变换,偶尔接受上升的变换以跳出局部最优。

第二条是E-graph 与等式饱和(equality saturation)。E-graph 是一种特殊的图结构,每个节点代表一个等价类,类的所有成员语义相同。把程序转成 E-graph 后,所有改写规则可以同时应用——应用规则不是替换,而是把新形式并入同一个等价类。规则反复应用直到不再产生新节点(饱和),然后从每个等价类里挑代价最小的成员,得到最终程序。

# E-graph 的核心操作:把等价形式并入同一个类
class EGraph:
    def __init__(self):
        self.classes = {}       # eclass_id -> set of enodes
        self.uf = {}            # 并查集,维护类之间的合并

    def add(self, op, children):
        """添加节点,返回它所属的等价类 id"""
        key = (op, tuple(children))
        cid = self.uf.setdefault(key, len(self.classes))
        self.classes.setdefault(cid, set()).add(key)
        return cid

    def merge(self, a, b):
        """合并两个等价类,这是所有改写规则的统一形式"""
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        self.uf[rb] = ra
        self.classes[ra] |= self.classes[rb]
        del self.classes[rb]
        return True

    def rebuild(self):
        """合并后重建:把子节点重定向到已合并的类(需 worklist 收敛)"""
        pass

# 用 E-graph 表示 (x * 2) + (x * 2),同时应用 a*2 => a<<1 与 a+a => a*2
# 结果:移位形式、乘 2 形式、4*x 形式共存于同一等价类,最后按代价挑选

E-graph 的关键优势是避免了相位排序问题(phase ordering problem):传统优化里,先做常量折叠还是先做代数简化,结果可能不同,必须试很多顺序。等式饱和把所有顺序的结果同时装进图里,最后统一择优。

4. 等价性验证

一句话总结: 超级优化器会生成大量候选序列,必须对每个候选证明它与原序列等价,否则优化就变成了引入 bug 的机器。

超级优化器的输出是「我搜到了一个更短的序列」,但这不等于「它与原序列等价」。搜索过程中的剪枝、代价模型的近似、甚至规则本身的错误,都可能产生不等价的候选。所以验证是超级优化器不可分割的一半。

4.1 随机测试与 SMT 求解

一句话总结: 随机测试用海量随机输入快速排除绝大多数错误候选,SMT 求解对幸存者给出可证明的等价性结论,两者构成从快到严的漏斗。

随机测试是最经济的第一道筛子。给两个序列喂同样的随机输入,比较输出。一个不等价的候选通常在几十次随机测试内就会暴露。这道筛子的成本极低,能淘汰 99% 以上的错误候选。

import random

def differential_test(orig, cand, n=10000, bits=32):
    """差分测试:随机输入下比较两个序列的输出"""
    mask = (1 << bits) - 1
    for _ in range(n):
        args = [random.getrandbits(bits) for _ in range(arity(orig))]
        if (eval_seq(orig, args) & mask) != (eval_seq(cand, args) & mask):
            return False, args       # 找到反例,立即拒绝
    return True, None                # 未找到反例,进入下一道筛子

但随机测试只能证伪不能证真。通过了十万次随机测试的候选,仍可能在某个特定输入上失败。第二道筛子是SMT 求解:把两个序列编码成一阶逻辑公式,交给 Z3、CVC5 这类求解器判断 orig != cand 是否可满足。如果不可满足,等价性得证。

# 用 Z3 证明两个位向量表达式等价
from z3 import BitVec, Solver

x = BitVec("x", 32)
s = Solver()
s.add((x * 2) + (x * 2) != x * 4)    # 断言两者不等
print(s.check())                     # unsat -> 等价成立
s.reset()
s.add((x << 1) != x * 2)
print(s.check())                     # unsat -> 移位与乘 2 等价
验证手段强度成本适用规模
随机差分测试只能证伪极低任意长度
位精确穷举完全(限于位宽)中输入位宽 ≤ 24
SMT 求解可证明高表达式树
交互式定理证明可证明极高关键规则

位精确穷举是一个被低估的实用手段:如果序列的输入只有一两个 8 位或 16 位参数,可以枚举全部 65536 种输入组合,得到完全确定的结论。对大量指令选择规则来说,这比 SMT 更快也更可靠。

# STOKE 的典型用法:MCMC 搜索 + 随机测试验证,是超级优化的工程化代表
stoke optimize --backend sandbox --init asm_orig.s --out asm_opt.s

5. 指令选择与超级优化

一句话总结: 指令选择是超级优化最早也最成功的应用场景:把 IR 的表达式树映射成机器指令时,本来就存在多种覆盖方式,超级优化可以在其中挑代价最小的。

指令选择(instruction selection)要做的是把 IR 里的操作映射到目标机器的指令。一个 IR 表达式树可能有多种指令覆盖方案:

// IR: t1 = x + y ; t2 = t1 * 4 ; t3 = t2 + z
// 方案 A(三条指令):add t1,x,y ; shl t2,t1,2 ; add t3,t2,z
// 方案 B(两条指令,利用 lea 的 base + index*scale + disp 能力):
//   add t1, x, y
//   lea t3, [z + t1*4]
// 方案 B 少一条指令,且不占用乘法端口
int sel(int x, int y, int z) {
    return (x + y) * 4 + z;
}

传统做法是用树覆盖(tree tiling)加动态规划:自底向上为每个子树计算最小代价,记录最优覆盖。这个方法快,但只考虑树形结构,遇到 DAG(共享子表达式)或多结果指令(如 x86 的 div 同时产生商与余数)就无能为力。

超级优化的思路是把指令选择当作搜索问题:允许重复计算(牺牲一点冗余换取更多覆盖可能),在更大的候选空间里搜最小代价。代价模型在这个场景里非常关键,因为它要在「少一条指令但多用乘法端口」与「多一条指令但端口均衡」之间做取舍。

6. 工程实现与工具

一句话总结: 窥孔优化在所有编译器里都有,超优化则主要活在研究原型与少数生产系统里,两者通过「离线发现规则、在线应用规则」的方式结合。

生产编译器里的窥孔优化实现方式各有不同:

编译器机制特点
GCCpeephole2 pass + define_peephole2用 RTL 模式语言描述,可跨基本块
LLVMPeepholeOptimizer + DAG combineDAG 合并与机器码窥孔分两阶段
Gorewrite.go 生成的规则表用 Go 语法描述规则,代码生成器展开
LuaJITDynASM 内联的手工规则JIT 编译期即时应用

Go 编译器的做法特别值得学习:它把窥孔规则写成看起来像 Go 表达式的文本,用 gen/rulegen.go 生成匹配代码。这样规则可读、可测试,规则表能长到上千条而仍然可维护。

// Go 编译器窥孔规则的形式(简化示意)
// (Add64 x (Const64 [c])) && is32Bit(c) -> (ADDQconst [c] x)
// (Mul64 x (Const64 [c])) && isPowerOfTwo(c) -> (SHLQconst [log2(c)] x)
// (Eq64 x (Const64 [0])) -> (TESTQ x x)

超优化的工程化代表是 STOKE、Souper 与 egg/egglog 系列。Souper 的思路与纯搜索不同:它把 LLVM IR 转成 SMT 公式,用求解器为每条指令合成候选改写,再用求解器验证等价性。这个流程完全离线,产出的规则被手工审查后合入 LLVM——本质上是用超优化发现规则,再用窥孔优化应用规则。

# Souper 的工作流:为 IR 片段合成更优实现
souper-check -infer-rhs -souper-external-cache ir.ll
# 输出形如:infer %x
#           %r = mul %x, 8
#         replace %r with shl %x, 3
# 人工审查后可作为 peephole 规则加入后端

7. 边界与陷阱

一句话总结: 窥孔优化的危险在于规则之间的相互作用与代价模型的失真,超优化的危险在于搜索空间爆炸与验证不可靠。

陷阱表现应对
规则冲突两条规则互相改写形成循环代价单调递减保证终止
不动点不收敛迭代次数无上限设最大轮数或按代价剪枝
代价模型失真删了指令反而变慢用真实硬件标定模型
忽略副作用消除访存破坏了 volatile 语义规则必须声明副作用约束
搜索空间爆炸超优化跑不完限制序列长度,用 E-graph 压缩
验证不充分通过了随机测试但不等价关键规则上 SMT 或穷举
调试信息丢失改写后行号错位保留 IR 到源码的映射
// 一个真实的踩坑例子:看似无害的代数简化
// 规则:x - x => 0
// 但如果 x 是浮点数,x - x 在 x = NaN 或 x = Inf 时是 NaN,不是 0
// 规则:x * 0 => 0
// 同样在浮点下失效,且会丢失 x 求值可能触发的异常
double f(double x) {
    return x - x;      // 对 NaN 返回 NaN,不是 0.0
}

// 正确的规则必须区分域:
//   整数域:x - x => 0 合法
//   浮点域:需要 -ffast-math 或 -ffinite-math-only 才允许

另一类陷阱是窥孔优化与调试体验的冲突。一条被消除的指令如果正是断点位置,调试器会无法命中。现代编译器通过保留位置信息、在 -O0 关闭窥孔、以及在 -Og 保留部分规则来缓解。

# 观察窥孔规则是否生效
cc -O2 -fdump-rtl-peephole2 -c file.c      # GCC:peephole2 改写记录
llc -debug-only=isel,dagcombine file.ll    # LLVM:DAG combine 每一步

8. 总结

环节要点
窥孔优化小窗口滑过指令序列,模式匹配后替换为更优等价序列
实现要点窗口 2 到 5 条、迭代到不动点、规则按具体度排序
规则类别冗余访存、常量传播、强度削减、代数简化、指令合并
代价模型延迟、吞吐倒数、体积三元组,按目标函数加权
超优化把规则发现交给搜索,找到人想不到的指令组合
搜索方法暴力枚举剪枝、MCMC 随机游走、E-graph 等式饱和
等价验证随机差分测试做粗筛,SMT 与穷举做证明
指令选择树覆盖加动态规划,或搜索式覆盖求最小代价
工程结合离线用超优化发现规则,在线用窥孔规则应用

窥孔优化与超优化代表了优化的两个极端:一个只做最小的局部改动但极其廉价可靠,一个敢于探索整个等价程序空间但需要严格的验证护栏。它们共同回答了一个问题——当优化器只知道局部的指令序列时,它还能走多远。答案是:比直觉远得多,只要它同时拥有一个诚实的代价模型和一把可靠的等价性尺子。下一篇我们把这个「尺子」放大到整个程序的尺度,看看抽象解释与形式化验证如何用格上的不动点计算,证明程序永远不会出错。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「compiler」更多文章

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