Lec 18代码生成 I(Code Generation I)——为什么要编译到机器码
Lec 18 代码生成 I(Code Generation I)——为什么要编译到机器码
进入 Phase 5。本讲讲动机:解释字节码到底慢在哪、性能问题的三类来源、以及把字节码"部分求值/特化"为机器码能省下多少(指令数从上千降到 9)。并梳理何时编译(静态 / AOT / JIT / 自适应)。
TL;DR
- 字节码解释的成本来自指令分派、通用栈操作以及动态类型 / 边界检查,这些工作会在热路径上反复发生。
- 机器码生成可视为对解释器和给定字节码的部分求值:把已知指令、常量和路径预先特化,仅保留运行时输入相关的工作。
- AOT、JIT 与自适应编译在编译开销、可用运行时信息和代码质量之间作不同取舍,没有对所有工作载都最优的统一时机。
1. 翻译的连续层级(回顾)
Language → High-Level VM → Low-Level VM → Machine Code。同一个 fun(y){ x = y - 2; }:
字节码:
load_local 0 load_const 1
sub store_local 1
load_const 0 return
IR(带 alloca 的 SSA 风格):
function __function_1 (y) {
%x = alloca int64_t
%2 = sub %y 2
store %x %2
return 0
}
机器码(x86-64):
__function_1:
sub 8, %rsp ; 开栈帧
mov %rsp, %rbp
mov (%rbp,2,8), %rax
mov 2, %rcx
sub %rcx, %rax
mov %rax, (%rbp)
mov 0, %rax
ret
为什么要往下走?一个字:性能 (Performance)。
2. 翻译任务的本质矛盾
定义(Language ↔ Machine 的鸿沟)
| 语言侧 | 机器侧 |
|---|---|
| 资源无限(任意多变量/临时值) | 资源有限(寄存器、CPU、ALU 数量固定) |
| 没有性能规格说明 | 对性能极度敏感 |
代码生成的任务就是把"无限资源、无性能约束"的语言,映射到"有限资源、性能敏感"的机器上。
2.1 性能问题的三大来源
定义(性能问题来源)
-
实现 (Implementation):实现方式太朴素(如解释器循环本身的开销);
-
语言/抽象 (Language/Abstraction):语言本身强制了一个非最优的抽象——动态语言到处要类型检查、对象 shape 还会变;
-
机器限制 (Machine Limitations):已经把机器能力用到极限了(这是"好问题")。
前两类是我们能优化的,第三类是物理上限。
3. 你的解释器(多半长这样)
void interpret(Function *func) {
Frame f = ...; OperandStack s;
Instruction* inst = func->instructions().begin();
while (inst != func->instructions().end()) {
switch (inst->operation()) {
case Operation::LoadLocal:
s.push(f.locals[inst->operand0]); break;
case Operation::LoadConst:
s.push(f.constants[inst->operand0]); break;
case Operation::Sub: {
Integer* op2 = s.pop_int();
Integer* op1 = s.pop_int();
auto* result = new Integer(op1->value - op2->value);
s.push(result); break;
}
...
}
++inst;
}
}
这段代码里藏着三类典型性能问题。
3.1 解释器循环(实现问题)
问题(数据依赖的控制流)
-
问题:控制流依赖于操作码(switch 的分发);
-
症状:在流水线处理器上性能差——每条字节码执行完后,下一条取哪儿不确定,分支预测失败、流水线停顿,直到地址解析出来;
-
解法:对控制流做部分求值,消除集中式分发跳转 → 线索化代码 (Threaded Code)。 :::
3.2 操作数栈(语言/抽象问题:栈式 VM)
::: example 问题(临时值进内存)
-
问题:中间结果都存到(内存里的)操作数栈;
-
症状:内存慢,而片上寄存器明明可用却没用上;
-
解法:用寄存器存临时值 → 栈缓存 (Stack Caching)(本质是寄存器分配)。 :::
3.3 装箱值(语言问题)
::: example 问题(值是堆对象)
-
问题:整数、布尔等都是对象(
new Integer(...)); -
症状:大量内存访问与内存分配,极慢;
-
解法:拆箱 (Unbox) 整数(配合标签指针 tagged pointers)。 :::
4. 部分求值:把字节码"摊平"成直线代码
针对静态已知的信息特化代码(一种通用技术)。这里:操作码、操作数下标在编译期都已知,于是可以把解释器循环"展开"成针对这段字节码的专用函数,消除 switch 分发。
对上面的 load_local 0; load_const 1; sub; store_local 1; load_const 0; return,部分求值出专用函数:
void execute_0() {
Frame f = ...;
push(f.locals[0]);
push(f.constants[1]);
Integer* op2 = pop_int(stack);
Integer* op1 = pop_int(stack);
auto* result = new Integer(op1->value - op2->value);
push(result);
Value* op3 = pop(stack); f.locals[1] = op3;
push(f.constants[0]);
return pop(stack);
}
没有 switch、没有 ++inst 的间接跳转了。但这还不够——还有栈操作和装箱。继续优化(栈缓存 + 拆箱 + 标签指针)后,整个东西塌缩成 9 条机器指令:
__execute_0:
push %rdi
call assert_integer ; 类型检查
pop %rdi
mov %rdi, %rax
shr $3, %rax ; 拆箱(去掉低位标签)
mov $2, %rcx
sub %rcx, %rax ; 真正的减法
shl $3, %rax
or $1, %rax ; 重新装箱(打标签)
ret
::: theorem 定理(优化收益,对这段代码)
-
指令数:>1000s → 9
-
函数调用:10s → 1
-
内存访问:100s → 2
-
内存分配:>1 → 0 :::
5. "怎么做":用到的多种优化
::: definition 定义(关键优化技术)
-
栈缓存 (Stack Caching) ≈ 寄存器分配:临时值尽量放寄存器而非栈;
-
标签指针 (Tagged Pointers):把小整数等直接编码进指针的低位,避免堆访问(运行时表示优化);
-
抽象解释 / 基于数据的优化:
-
特化 (Specialization):已知 x、y 都是整数时,
x + y直接用算术指令、跳过类型检查; -
常量折叠 (Constant Folding):
1 + 2 ⇒ 3; -
强度削减 (Strength Reduction):
x / 2 ⇒ x >> 1。 :::
6. 何时编译?(When)
::: definition 定义(编译时机分类)
-
静态 (Static):部署前就把应用编译好(C/C++/Fortran);
-
动态 (Dynamic):
-
提前编译 AOT (Ahead-of-Time):分发中间表示,加载时编译;
-
即时编译 JIT (Just-in-Time):按需编译——某个块/函数/模块只有在真要执行时才编译;
-
自适应 / 剖析驱动 (Adaptive / Profile-Driven):先解释执行一次或多次,收集热点信息后再编译。 :::
6.1 不同语言的选择
- "裸金属"语言(C/C++/Fortran):选静态编译求可预测性能;平台已知,代码可含平台特定优化。
- 现代语言:用各种形式的动态编译——
- 分发的中间表示设计成可移植;
- 需在代码体积与编译时间间权衡(编出的机器码通常很大,不适合 Web 场景);
- 开发循环要紧凑,不想等编译;增量 AOT 很难做对。
实现版图(同 L15 的图):Java→JVM→JIT;Swift→SIL→LLVM;Python→Python VM;JS→解释+JIT;C/C++→LLVM/GIMPLE→机器码。
7. 接下来两讲的路线
::: definition 定义(后续安排)
-
未优化代码生成:把 VM 表示直接映射到机器表示。目标——熟悉机器(L20);
-
高级代码生成:先生成可做静态分析的中间表示,再据分析做更高效的代码生成(L19 优化、L21–L22 寄存器分配、L23–L24 静态分析)。 :::
8. 延伸阅读(slides 列出)
- Bell, "Threaded Code", CACM 1973(线索化代码)。
- Ertl, "Stack Caching for Interpreters", EuroForth 1994;Ertl & Gregg, "The Structure and Performance of Efficient Interpreters", 2003。
- "Let's Build Tagged Pointers"(mikeash 博客)。
- Chambers, Ungar, Lee, "An Efficient Implementation of SELF…", 1991(原型语言的动态类型高效实现、特化思想的源头)。
参考书对应:Crafting Interpreters 第 30 章 Optimization(NaN-boxing 即标签指针、哈希表加速)正是本讲拆箱/标签指针/特化的实战。
9. 本讲小结
- 往机器码下沉只为一件事:性能。性能问题来自实现、语言抽象、机器限制三处,前两者可优化。
- 朴素解释器的三大病灶:分发跳转停流水线(→线索化代码)、临时值进内存栈(→栈缓存/寄存器)、值装箱(→拆箱+标签指针)。
- 部分求值把字节码特化成直线机器码,配合拆箱/特化,一段减法从上千指令塌缩到 9 条、零分配。
- 常用优化:栈缓存、标签指针、特化、常量折叠、强度削减。
- 编译时机谱系:静态 / AOT / JIT / 自适应;裸金属语言求可预测性走静态,现代语言走动态编译并权衡码体积与编译时间。
- 下一讲(L19)正式讲优化:在 IR 上做静态分析驱动的变换。