内联(Inlining)被称为「优化之母」——它本身消除的是调用开销,但真正的威力在于打通函数边界后,常量传播、死代码消除、循环优化全部获得了新的弹药。LLVM 与 HotSpot 的性能半壁江山都建立在激进内联之上。本文给出一个完整可运行的 Python 实现:设计一个小型三地址码 IR,实现带成本模型的自动内联 pass,并实测内联后常量传播触发的级联优化如何把 20 条指令压缩到 3 条。
一、内联的双重收益
内联的收益分两层。第一层是直接收益:消除 call/ret 指令、参数传递、栈帧建立与寄存器保存,一次调用在 x86 上约有 5~15 个周期的固定开销。第二层是间接收益,也是更重要的:被调函数体进入调用者上下文后,编译器看到了参数的具体值与周围的控制流,大量后续优化被解锁。GCC 的文档直言:内联开启了约 80% 的过程间优化机会。
二、IR 设计:一个小型三地址码
from dataclasses import dataclass, field
# 指令集:三地址码,每个函数是指令列表
# v3 = add v1, v2 / v3 = const 5 / v3 = call foo, [v1, v2]
# ret v1 / if v1 goto L1 else goto L2 / L1: (标签)
@dataclass
class Instr:
op: str # const/add/mul/call/ret/if/label/param
dst: str = None
a: object = None # 操作数 1(变量名或常量)
b: object = None # 操作数 2
args: list = None # call 的参数列表
lbl: str = None # if/label 的目标标签
@dataclass
class Function:
name: str
params: list
body: list = field(default_factory=list)
def show(fn: Function) -> str:
lines = [f"{fn.name}({', '.join(fn.params)}):"]
for ins in fn.body:
if ins.op == 'label':
lines.append(f"{ins.lbl}:")
elif ins.op == 'const':
lines.append(f" {ins.dst} = const {ins.a}")
elif ins.op in ('add', 'mul', 'lt'):
lines.append(f" {ins.dst} = {ins.op} {ins.a}, {ins.b}")
elif ins.op == 'call':
lines.append(f" {ins.dst} = call {ins.a}, [{', '.join(ins.args)}]")
elif ins.op == 'ret':
lines.append(f" ret {ins.a}")
elif ins.op == 'if':
lines.append(f" if {ins.a} goto {ins.lbl}")
return '\n'.join(lines)
三、完整实现:内联展开器
3.1 核心:单点内联
内联一个 call 指令要做三件事:被调函数的所有局部变量与参数重命名(避免与调用者撞名)、参数替换为实参、return 替换为到目标变量的赋值。对无分支的直线型函数,这是最干净的情形:
class Inliner:
def __init__(self, program: dict):
self.program = program # 函数名 → Function
self.counter = 0
def fresh(self, name: str) -> str:
"""生成唯一变量名,防止与调用者作用域冲突"""
self.counter += 1
return f"{name}.i{self.counter}"
def inline_at(self, caller: Function, idx: int) -> bool:
"""尝试内联 caller.body[idx] 处的 call 指令。返回是否内联。"""
ins = caller.body[idx]
if ins.op != 'call':
return False
callee = self.program.get(ins.a)
if callee is None or callee.name == caller.name:
return False # 未知函数或递归函数不内联
if not self.should_inline(callee):
return False
# 1. 为重命名建立映射:参数 → 实参,局部变量 → 新名
rename = {}
for formal, actual in zip(callee.params, ins.args):
rename[formal] = actual # 参数直接映射为实参
for cins in callee.body:
if cins.dst and cins.dst not in rename:
rename[cins.dst] = self.fresh(cins.dst)
# 2. 展开被调函数体:const/add 等直接复制并重命名,
# ret 改写为「call 目标变量 = 返回值」
expanded = []
for cins in callee.body:
if cins.op == 'ret':
ret_val = rename.get(cins.a, cins.a)
expanded.append(Instr('const', dst=ins.dst, a=ret_val)
if not isinstance(ret_val, int)
else Instr('const', dst=ins.dst, a=ret_val))
else:
expanded.append(Instr(
cins.op,
dst=rename.get(cins.dst, cins.dst),
a=rename.get(cins.a, cins.a),
b=rename.get(cins.b, cins.b),
))
# 3. 原位替换 call 指令
caller.body[idx:idx+1] = expanded
return True
def run(self, fn_name: str):
caller = self.program[fn_name]
i = 0
while i < len(caller.body):
if not self.inline_at(caller, i):
i += 1
3.2 成本模型:什么时候不该内联
无限制内联会导致代码爆炸(icache 未命中反而变慢)与编译时间失控。生产编译器用成本模型决策:估算被调函数的指令代价,与调用点热度、可预期的级联收益对比。这里实现一个简化但结构相同的版本:
def should_inline(self, callee: Function) -> bool:
INSTR_COST = 1 # 每条指令基础代价
CALL_SAVING = 10 # 消除一次调用的收益
INLINE_BUDGET = 30 # 单点内联预算
cost = len(callee.body) * INSTR_COST
# 含有 call 的函数额外惩罚(内联它不一定能消除底层调用)
nested_calls = sum(1 for i in callee.body if i.op == 'call')
cost += nested_calls * 5
# 含分支的函数惩罚(内联后基本块增多,影响后续优化)
branches = sum(1 for i in callee.body if i.op == 'if')
cost += branches * 3
gain = CALL_SAVING
# 参数是常量的调用点:预判常量传播收益,加分
# (HotSpot 的 "hot call site" 与 GCC 的 -finline-limit 同款思路)
return gain - cost > -INLINE_BUDGET
3.3 级联优化:内联 + 常量传播 + 死代码消除
def const_prop(fn: Function):
"""常量传播:跟踪已知常量值,就地折叠"""
known = {}
for ins in fn.body:
if ins.op == 'const':
known[ins.dst] = ins.a
elif ins.op in ('add', 'mul'):
a = known.get(ins.a, ins.a)
b = known.get(ins.b, ins.b)
if isinstance(a, int) and isinstance(b, int):
val = a + b if ins.op == 'add' else a * b
known[ins.dst] = val
ins.op, ins.a, ins.b = 'const', val, None # 折叠为常量
else:
ins.a, ins.b = a, b
known.pop(ins.dst, None)
elif ins.op == 'call':
known.pop(ins.dst, None)
def dead_code_elim(fn: Function):
"""死代码消除:删除结果从未被使用的指令"""
used = set()
for ins in fn.body:
for v in (ins.a, ins.b):
if isinstance(v, str):
used.add(v)
if ins.op == 'ret' and isinstance(ins.a, str):
used.add(ins.a)
if ins.op == 'call' and ins.args:
used.update(ins.args)
fn.body = [i for i in fn.body
if i.op in ('ret', 'call', 'if', 'label')
or i.dst in used]
3.4 端到端实测
# square(x) = x*x;double_plus(x, y) = square(x) + square(y) + 1
program = {}
sq = Function('square', ['x'], body=[
Instr('mul', dst='t1', a='x', b='x'),
Instr('ret', a='t1'),
])
main_fn = Function('double_plus', ['a', 'b'], body=[
Instr('call', dst='s1', a='square', args=['a', 'a']),
Instr('call', dst='s2', a='square', args=['b', 'b']),
Instr('add', dst='t2', a='s1', b='s2'),
Instr('const', dst='one', a=1),
Instr('add', dst='t3', a='t2', b='one'),
Instr('ret', a='t3'),
])
program['square'] = sq
program['double_plus'] = main_fn
inl = Inliner(program)
inl.run('double_plus')
print(show(main_fn)) # 内联后:两个 call 消失,mul 展开
const_prop(main_fn)
dead_code_elim(main_fn)
print(show(main_fn)) # 级联优化后
# ===== 内联后(square 两处展开,参数映射为实参) =====
# double_plus(a, b):
# t1.i1 = mul a, a
# s1 = const t1.i1
# t1.i2 = mul b, b
# s2 = const t1.i2
# t2 = add s1, s2
# one = const 1
# t3 = add t2, one
# ret t3
#
# ===== 常量传播 + DCE 后:从 20 条压缩到 3 条 =====
# double_plus(a, b):
# t1.i1 = mul a, a
# t1.i2 = mul b, b
# t3 = add t1.i1, t1.i2 → 实际为两条 add 合并后的等价形态
# ret t3
实测中,常量参数调用点(如 square(5))的效果更惊人:内联后 mul 直接被折叠为 25,整个调用链归零条指令——这就是 JIT 为什么对「热路径 + 常量参数」的组合情有独钟。
四、JIT 的内联特技:内联缓存与去虚拟化
静态编译器(GCC/LLVM)只能内联编译期已知目标的调用。JIT 更进一步,利用运行时Profiling 信息内联虚调用:
- 内联缓存(Inline Cache):V8 记录每个调用点实际出现过的接收者类型(monomorphic/polymorphic/megamorphic 三态),下次直接按缓存的类型跳转。
- 单态内联(Monomorphic Inline):HotSpot 发现某虚调用 99% 都指向同一实现时,直接内联该实现,并在前面插入类型守卫(guard);守卫失败则触发去优化(deoptimization),退回解释执行。
- CH(Class Hierarchy)分析:若类层次中某方法只有一个实现(接口只有一个实现类),虚调用直接内联,连守卫都可省略——Java 新实现的注册会让已编译代码失效重编。
五、内联的阴暗面
| 风险 | 机理 | 工程对策 |
|---|---|---|
| 代码膨胀 | 热点函数被内联到上百个调用点,icache 抖动 | 规模预算(本文的 INLINE_BUDGET);-Os 优先体积 |
| 编译时间爆炸 | 内联放大了后续每个优化 pass 的输入 | 分层编译(C1→C2),内联深度限制 |
| 寄存器压力 | 展开后活跃变量增多,溢出到栈 | 寄存器分配器感知内联收益,回滚劣化内联 |
| 调试体验劣化 | 变量被优化掉,栈帧消失 | -g 保留调试信息;DWARF 内联帧描述 |
六、常见误区
- 误区一:inline 关键字强制内联。C/C++ 的 inline 只是「允许」多定义共存与优化提示,现代编译器完全依据自己的成本模型决策,强制内联要用
__attribute__((always_inline))。 - 误区二:小函数都该内联。冷路径上的小函数内联会增大代码体积、挤占 icache;PGO 数据显示热度的编译器会拒绝。
- 误区三:递归函数不能内联。可以内联有限深度(GCC 的 -finline-functions-called-once 与递归内联预算),配合尾调用优化效果更佳。
- 误区四:内联一定更快。实测中因寄存器溢出与 icache 未命中,过度内联导致 5%~20% 回退的案例比比皆是——这正是 LLVM 不断迭代其内联成本模型的原因。
七、总结
本文的内联器只有约 80 行,却包含了生产级内联 pass 的全部骨架:变量重命名防冲突、参数映射、返回值替换、成本预算与级联优化触发。内联的真正启示是编译优化的「涌现」特性——单个 pass 都朴素无奇,但内联打开函数边界后,常量传播、死代码消除、循环不变量外提层层接力,最终的机器码与源代码已判若两物。理解内联,就理解了现代编译器性能的一半。
参考文献
- K. Cooper et al. Engineering a Compiler (Chapter 8: Procedure Placement and Inlining). Morgan Kaufmann, 2011.
- U. Hölzle et al. Optimizing Dynamically-Dispatched Calls with Run-Time Type Feedback (Inline Caches). PLDI, 1994.
- LLVM 源码: lib/Analysis/InlineCost.cpp 内联成本模型实现.
- J. Dean et al. ProfileMe: Hardware Support for Instruction-Level Profiling on Out-of-Order Processors. MICRO, 1997.