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. 工程实现与工具
一句话总结: 窥孔优化在所有编译器里都有,超优化则主要活在研究原型与少数生产系统里,两者通过「离线发现规则、在线应用规则」的方式结合。
生产编译器里的窥孔优化实现方式各有不同:
| 编译器 | 机制 | 特点 |
|---|---|---|
| GCC | peephole2 pass + define_peephole2 | 用 RTL 模式语言描述,可跨基本块 |
| LLVM | PeepholeOptimizer + DAG combine | DAG 合并与机器码窥孔分两阶段 |
| Go | rewrite.go 生成的规则表 | 用 Go 语法描述规则,代码生成器展开 |
| LuaJIT | DynASM 内联的手工规则 | 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 与穷举做证明 |
| 指令选择 | 树覆盖加动态规划,或搜索式覆盖求最小代价 |
| 工程结合 | 离线用超优化发现规则,在线用窥孔规则应用 |
窥孔优化与超优化代表了优化的两个极端:一个只做最小的局部改动但极其廉价可靠,一个敢于探索整个等价程序空间但需要严格的验证护栏。它们共同回答了一个问题——当优化器只知道局部的指令序列时,它还能走多远。答案是:比直觉远得多,只要它同时拥有一个诚实的代价模型和一把可靠的等价性尺子。下一篇我们把这个「尺子」放大到整个程序的尺度,看看抽象解释与形式化验证如何用格上的不动点计算,证明程序永远不会出错。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。