追查 antcc 中的 -O2 性能劣化
摘要
一篇深入的调试记录,展示了 antcc 的 -O2 性能劣化如何让一个 Lua 基准测试比未优化构建慢 1.4 倍,并逐步追踪 luaV_execute 中 2.4 倍的代码体积膨胀——从内联启发式与 IR 展开,一直排查到指令选择与寄存器分配。
<p><a href="https://lobste.rs/s/g2ktrq/chasing_o2_pessimization_antcc">Comments</a></p>
查看缓存全文
缓存时间: 2026/10/13 09:56
# 追踪 antcc 中一个 -O2 反优化问题
来源: https://386s.org/w/lua-antcc-pessimization/
2026 Oct 12 — [c](https://386s.org/tags/c)、[compilers](https://386s.org/tags/compilers)、[optimization](https://386s.org/tags/optimization)、[antcc](https://386s.org/tags/antcc)
最近我在为我的 C 编译器 [antcc](https://codeberg.org/lsof/antcc/) 做生成代码效率的基准测试,注意到一个令人担忧的现象——在一个简单的 Lua 基准测试中:
```
基准测试 优化级别 运行时间
lua 无 1.048s
lua -O1 1.077s
lua -O2 1.410s
```
"优化后的"构建反而更慢了。目前我的编译器还没有实现太多优化,所以我并不期望看到巨大的提升,但慢了 1.4 倍绝对不应该发生。[^1] antcc *确实*实现了的一个优化是内联,这通常是影响最大的编译器优化之一,通常能全面提升各项基准测试的表现。
## 内联?
我的第一个想法是:内联的启发式规则和阈值非常简单,基本上没有经过调优,所以可能是过于激进了,导致代码体积膨胀,进而引发严重的指令缓存冲突。如果我禁用内联,反优化现象似乎就消失了,这可以支持该理论。但这个解释并不太令人满意,而且我想修复它,所以我做了更深入的调查。
主要的罪魁祸首在函数 `luaV_execute`,其代码在 -O2 下膨胀到了约 43KB,**比 -O1 构建大约 2.4 倍**。
现在,[这个函数](https://github.com/lua/lua/blob/c33b1728aeb7dfeec4013562660e07d32697aa6b/lvm.c#L1127)是 Lua VM 的主字节码解释器循环,这类函数是编译器最难处理的函数之一:它是一个巨大的函数,包含一个热循环、大量分支和控制流、图中嵌套着不同的热路径和冷路径,一个错误的优化器或寄存器分配决策就可能拖慢整个循环……[^2] 基线函数本身就很难优化,但这并不能解释这个现象。调整内联阈值似乎也没有帮助:要么很少东西被内联(如 -O1),二进制大小和性能基本不变;要么小函数开始被内联,反优化就发生了。
具体来说,以下函数被内联了(有些在多个实例中):`floatforloop`、`lessequalothers`、`lessthanothers`、`luaV_idiv`、`luaV_mod`、`luaV_modf`、`luaV_shiftl`、`luaV_tointegerns`,以及它们各自的被调用者。在上面的 Lua 源代码链接中可以看到,这些都是一些相当小的简单函数。
查看最终生成的代码没有太大帮助,但检查 IR[^3] 揭示了一些有趣的事情:在所有优化之后,-O2 的 IR 仅比 -O1 **大 11%**。这怎么会在二进制中放大到 **2.4 倍**的差异呢?
## 代码生成?
在优化之后、代码生成之前运行的最终阶段是[指令选择](https://en.wikipedia.org/wiki/Instruction_selection)和[寄存器分配](https://en.wikipedia.org/wiki/Register_allocation)。指令选择主要负责确保指令操作数已准备好降级到机器代码,但临时变量仍然保持为虚拟寄存器。例如,对于 x86-64,它会建模内存操作数,并将这样的 IR 指令序列:
```
;; int *ptr, res;
i64 %t2 = shl %index, 2
ptr %t3 = add %ptr, %t2
;; res = ptr[index]
i32 %res = load i32 %t3
```
转换为:
```
i32 %res = load i32 addr [%ptr + %index * 4]
```
它还做其他事情,比如为某些操作引入寄存器约束,但不管怎样,由于它只展开成这样的小段指令序列,不可能是问题的原因。
## 寄存器分配
寄存器分配是将虚拟临时变量映射到目标架构物理寄存器的过程。对于上面的代码片段,可能得到:
```
i32 RAX = load i32 addr [RSI + RDX * 4]
```
它被编译为 x86 指令 `mov eax, DWORD PTR [rsi + rdx * 4]`。
问题在于虚拟临时变量是无限的,而寄存器数量有限,所以你必须选择哪些变量获得哪些寄存器,当所有变量不够用时,其中一些不得不放到栈上,这被称为*spilling*(溢出)。大多数操作要求值在寄存器中,所以被溢出的变量经常需要在寄存器之间来回移动。或者更糟的是,在某些情况下它们需要在两个不同的栈槽之间移动。
antcc 有一个基础的[线性扫描寄存器分配](https://c9x.me/compile/bib/Wimmer10a.pdf)实现。如果寄存器压力很大,很多临时变量被溢出,可能仅仅为了在栈和寄存器之间搬运值就会产生大量指令。
确实,比较 -O2 下寄存器分配前后的 IR 体积增长,+11% 的差异在寄存器分配后变成了 **+70%** 的膨胀……查看反汇编代码,`luaV_execute` 中**超过 40%** 的指令仅仅是栈⇄寄存器的 `mov` 操作。这真的很糟糕,某些被内联的东西一定导致了这种可怕的寄存器压力和溢出,但原因并不明显。
所以让我们检查寄存器分配前的 IR:
```
<< 寄存器分配前 >>
function luaV_execute : void(struct lua_State *, struct CallInfo *)
abi: (RDI, RSI)
....
ptr %4613 = add %0, 56
ptr %5340 = add %0, 40
b @2
@2:
; preds: @1, @892
; loop depth: 1
i64 %4489 = phi @1 undef, @892 %3124
i64 %3030 = phi @1 undef, @892 %3065
i64 %4636 = phi @1 undef, @892 %2287
i64 %4764 = phi @1 undef, @892 %3869
i64 %2874 = phi @1 undef, @892 %3929
i64 %2694 = phi @1 undef, @892 %3981
i64 %4872 = phi @1 undef, @892 %3804
i64 %2297 = phi @1 undef, @892 %3497
i64 %5062 = phi @1 undef, @892 %3158
i64 %2208 = phi @1 undef, @892 %3145
i64 %2161 = phi @1 undef, @892 %4191
i64 %5275 = phi @1 undef, @892 %4225
i64 %5290 = phi @1 undef, @892 %4252
i64 %1791 = phi @1 undef, @892 %4370
i64 %3813 = phi @1 undef, @892 %3195
i64 %3807 = phi @1 undef, @892 %4403
....
```
哦。这看起来很奇怪。
## SSA 与 phi
我的编译器和大多数现代后端一样,使用[静态单赋值](https://en.wikipedia.org/wiki/Static_single-assignment_form)(SSA)表示。在这种形式中,直线代码如(我使用类似 C 的伪代码来简化):
```
x = a + b
x = x + y # 重新赋值 x
use(x)
```
变为:
```
x1 = a + b
x2 = x1 + y # 新的定义
use(x2)
```
也就是说,没有变量变更指令。这种表示对编译器来说很好的原因是,它简化了各种数据流分析,并且比非 SSA 形式允许更强大的优化。
对于控制流,数据流合并在 IR 中是显式的:
```
if (cond) {
x = a + 1
} else {
x = b - 1
}
use(x)
```
在 SSA 形式中表示为:
```
if (cond)
B0: { x1 = a + 1 }
else
B1: { x2 = b - 1 }
x3 = phi(B0: x1, B1: x2)
use(x3)
```
这个神奇的 `phi` 指令[^4]的结果是 `x1` 或 `x2` 的值,取决于控制流走了哪个分支。同样的方法可以应用于在循环中被修改的变量:
```
i = 0
while (cond) {
use(i)
i += 1
}
```
在 SSA 中:
```
i0 = 0
Entry:
while (cond) {
LoopBegin:
i_l = phi(Entry: i0, LoopEnd: i_n)
use(i_l)
i_n = i_l + 1
LoopEnd:
}
```
从字面上看,`phi` 函数似乎在 `i_n` 定义之前就引用了它,但重要的是,该值仅在循环回边(`LoopEnd --> LoopBegin`)时被取用,在那个点上 `i_n` 的值是可用的。
这里需要意识到的一个关键是,`i_l` 这个临时变量是循环 phi,它的生命周期覆盖整个循环,因为它的值用于计算下一次迭代。
回到 Lua 的 IR 转储:
```
@2:
; preds: @1, @892
; loop depth: 1
i64 %4489 = phi @1 undef, @892 %3124
i64 %3030 = phi @1 undef, @892 %3065
...
```
先忽略 undef,注意 `; preds` 行表示该基本块的前驱:`@1`(函数的开始)和 `@892`(数百个块之后:主解释器循环的结束)。这意味着我们有 16 个 phi,其生命周期覆盖了整个循环。
这就是造成大量寄存器压力、破坏寄存器分配、并导致栈溢出流量造成代码体积大幅膨胀的原因。
那么,这些 phi 是什么?它们来自哪里?为什么会有 `undef`……
## mem2reg
C 程序不会以 SSA 形式开始,所以它们必须经过一个转换阶段。我在编译器中借鉴了 clang/LLVM 的术语和设计:
1. 变量总是从虚拟栈槽开始,用 `alloca` 伪指令定义。[^5] 因为 C 中局部变量可以取地址[^6],这直接建模了 C 抽象机对"自动变量"的定义:一个可以读写的可寻址物理栈槽。
2. 变量读取变成内存 `load` 指令,写入变成 `store` 指令。
3. 之后,`mem2reg` 阶段收集地址未逃逸的栈 `alloca`(意味着它们只用于编译器可观察的直接读写),并将它们"提升"为 SSA 临时变量,在需要的地方插入 phi 指令。
所以这样的 C 代码:
```
int x;
x = foo();
...
if (x < 0) {
x = -x;
}
return x;
```
最初生成的 IR 类似:
```
@1:
ptr %x = alloca 4 bytes
i32 %t1 = call foo()
store i32 %x, %t1
...
i32 %t2 = load i32 %x
bool %cond = lth %t2, 0
br %cond, true -> @2, false -> @3
@2:
i32 %t3 = load i32 %x
i32 %t4 = neg %t3
store i32 %x, %t4
b @3
@3:
i32 %t5 = load i32 %x
ret %t5
```
mem2reg 将其转换为:
```
@1:
i32 %x1 = call foo()
...
bool %cond = lth %x1, 0
br %cond, true -> @2, false -> @3
@2:
i32 %x2 = neg %x1
b @3
@3:
i32 %x3 = phi @1 %x1, @2 %x2
ret %x3
```
我跳过了其中最棘手的部分,即在一般情况下知道何时何地插入这些 phi 指令。基本上有两种算法,我使用的是来自论文["Simple and Efficient Construction of SSA Form" (Braun et al, 2013)](https://pp.ipd.kit.edu/uploads/publikationen/braun13cc.pdf) 的方法。
其要点是:以逆后序(RPO)遍历控制流图(CFG);当你看到一个写入时,按块记录当前定义;当你看到一个读取时,查找该定义或递归查询前驱的定义,在查找发现不同定义时插入 phi。循环更棘手:因为回边前驱尚未被访问,你必须插入一个临时 phi 并在之后解析。
C 中有一个几乎所有后来的语言都将其作为"坏主意"而移除的特性:允许变量不初始化。例如:
```
int x;
if (foo()) {
int y = bar();
x = y;
}
use(x);
```
在控制流合并之后,`x` 是 `phi(未初始化, y)`。这就是我 IR 中的 `undef`。在我的 mem2reg 中,它被引入的方式是:如果递归的"读取当前定义"函数一路到达函数开头都没有找到定义,就给它 `undef`。(之后,对 `undef` 的移动和操作可以被有效地优化掉。)
## 这在 Lua 中是如何表现的
那么 Lua 解释器是在读取未初始化的变量吗?其实不是。看看解释器函数中一些受影响的代码:
```
vmcase(OP_SHRI) {
TValue *rb = vRB(i);
int ic = GETARG_sC(i);
lua_Integer ib;
if (tointegerns(rb, &ib)) {
pc++;
setivalue(s2v(ra), luaV_shiftl(ib, -ic));
}
vmbreak;
}
// 还有几个其他指令遵循同样的模板
```
关键是 `tointegerns` 宏,它进而调用函数 `luaV_tointegerns`:
```
int luaV_tointegerns (const TValue *obj, lua_Integer *p, F2Imod mode) {
if (ttisfloat(obj))
return luaV_flttointeger(fltvalue(obj), p, mode); // <- 这个被内联了
else if (ttisinteger(obj)) {
*p = ivalue(obj);
return 1;
}
else
return 0;
}
```
在 -O2 下,这个函数和 `luaV_flttointeger` 被内联了,导致这样的展开(已简化):
```
vmcase(OP_SHRI) {
...
lua_Integer ib; // <--- 声明
int tointegerns_ret;
if (ttisinteger(rb)) {
ib = ivalue(rb); // <--- 写入
tointegerns_ret = 1;
}
else if (ttisfloat(rb)) {
...
ib = (lua_Integer)fvalue(f); // <--- 写入
tointegerns_ret = 1;
}
else {
tointegerns_ret = 0;
}
if (tointegerns_ret) {
... (使用 ib)
}
vmbreak;
}
```
`mem2reg` 在每次内联之后都会运行。问题中的 `undef` 来自我标记的局部变量 `ib`。你可以看到,在内联函数返回 `1` 的每条路径中局部变量都被初始化了,但 `mem2reg` 无法将此与后面的 `if (tointegerns_ret)` 守卫关联起来,所以它看到了一个可能未初始化的读取。
但这*并不是*全部的故事!它解释了 `undef`,但没有解释为什么 phi 的生命周期覆盖整个循环。毕竟,这个局部变量作用域在循环中一个分支内的代码块里,它不应该跨越循环迭代……
## 问题所在
最终我找到了根本原因:我在 mem2reg 中对"变量以未初始化开始"这个概念理解*过于*字面了。当它遇到一个 `alloca` 时,它没有为当前块给它一个定义。
这听起来是对的,算法中的递归查找在函数入口以 `undef` 终止。但回想循环的陷阱:当你遇到一个回边时,你不知道是否有后来的定义,因为你还没有访问整个循环,所以你插入一个临时 phi……
本质上,我的 mem2reg 的行为就好像这样的代码:
```
int foo() {
while (...) {
int x;
if (...) {
x = bar();
}
use(x);
}
}
```
实际上是:
```
int foo() {
int x;
while (...) {
if (...) {
x = bar();
}
use(x);
}
}
```
在第二个函数中,`x` 的值跨迭代保留;而在第一个函数中,每次 `if` 分支未被采纳时,`x` 的值都是一个'全新的'未初始化值……
这并不是一个错误编译:就 C 而言,这是没问题的,因为未初始化变量的值本来就不可合法使用。如果从栈分配变量的角度来看,这是一种合理的方法:x 的栈槽在整个函数中是固定的,不会在循环迭代之间重置,所以它保留了上一次迭代的值,无论那是什么,都无关紧要。
但在 SSA 形式下,这会生成类似这样的代码:
```
while (...) {
%x = phi(undef, %x2)
if (...) {
%x1 = bar();
}
%x2 = phi(%x, %x1)
use(%x2);
}
```
正是这些循环 phi 导致了病态的寄存器分配。
修复只需一行:
```
--- a/src/ir_mem2reg.c
+++ b/src/ir_mem2reg.c
@@ -244,6 +244,8 @@ mem2reg(Function *fn)
v->cls = k;
v->i = var;
sb.vars[var] = v;
+ v->curdefs[blk->id] = UNDREF;
} else {
sb.vars[var] = DEAD;
}
```
在 `alloca` 的位置将变量初始化为未定义(一个漂亮的矛盾修辞)确保了递归查找不会跨越循环回边并在循环头插入 phi。这就足以修复这个情况,这个改动让那个神秘的反优化消失了。
## 结语
这个问题对于那些老式地在函数顶部声明所有变量的 C 代码仍然可能出现。可以通过使用 [Tarjan 算法](https://en.wikipedia.org/wiki/Tarjan's_strongly_connected_components_algorithm)查找 phi 数据流的图强连通分量来检测循环,从而修复这个一般情况,并在将 phi 分类为冗余时利用这些 undef 语义;但我还没有实现这个。跳转线程化(jump threading)直接被用作分支条件的内联函数,或者更一般地,SSA 感知的 phi 跳转线程化,也可以修复那个分支关联问题——正是那个问题导致 `mem2reg` 看不到那些变量的支配性写入从而引入了 undef。
我使用了 AI 智能助手来……
相似文章
对FPU模拟器进行超优化
一篇博客文章,描述了作者如何在短公式空间中进行暴力搜索,以超优化浮点数最低置位比特的计算,在约4分钟的搜索时间内消除了针对2的幂的分支特例。
LuaJIT 中的一个 NYI 操作悄然毒害了无关的热循环
本文探讨了 LuaJIT 的一个陷阱:诸如 unpack 之类的未实现(NYI)操作会悄然导致 trace 黑名单化,从而使基准测试性能降低 20 倍,并提供了在 CI 中防范此类问题的方法。
回复:关于在LuaJIT2中实现高效指令集模拟器的建议(2011年)
这个2011年的邮件列表讨论串探讨了编译器在优化解释器循环方面的挑战,以及LuaJIT2如何通过固定寄存器分配等汇编级技术实现更好的性能。
调试BPF中基于类型的别名分析优化
文章描述了如何将BPF程序切换到使用BTF生成的vmlinux.h,由于Clang的基于类型的别名分析优化,导致IP校验和不正确,并通过汇编检查详细说明了调试过程。
当编译器让你惊喜
Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。