从零编写现代编译器 26 - 用 Cranelift 实现 JIT 编译
前 25 篇的代码生成路径是固定的:Sprout SSA → LLVM 文本 IR → opt 优化 → llc 编译成机器码 → 链接成可执行文件。整个流程产出的是一个本地二进制文件,运行前必须经过完整的编译和链接。LLVM 的优化器能生成很快的机器码,但编译本身不快——一个千行级的 Sprout 程序走完 LLVM 流水线可能需要数百毫秒。对于发布构建这完全可以接受,但如果想做交互式开发——输入一段代码、立即看到结果——几百毫秒的延迟就太明显了。
这篇引入第二个代码生成后端:Cranelift。它是 Bytecode Alliance 开发的代码生成库,设计目标是编译速度优先、生成质量够用。用它替代 LLVM 之后,编译到可执行的函数指针只需要几毫秒,代价是生成的机器码比 LLVM O2 慢大约两倍。
Cranelift 的架构
Cranelift 不是一个完整的编译器框架,而是一个函数级别的代码生成库。它的输入是自己的中间表示 CLIF(Cranelift IR),输出是特定目标架构的机器码。没有链接器,没有目标文件——调用 API 传入 CLIF,拿回一段可执行的机器码字节和重定位信息。
CLIF 是一种 SSA 形式的中间表示,和我们在第 12 篇构造的 SSA 形式结构非常接近。它有基本块、块参数(替代 phi 节点)、显式的控制流终结器。类型系统比 LLVM IR 简单:整数类型有 i8、i16、i32、i64,浮点有 f32、f64,指针用 i64(在 64 位目标上)直接表示。没有结构体类型、没有向量类型——这些需要在前端手动展开成标量操作和显式内存访问。当前端遇到 Cranelift 不原生支持的类型(如结构体、向量类型),应在翻译阶段显式报错而非静默降级。例如:error: Cranelift 后端不支持结构体类型,请先手动展开为标量字段。
Cranelift 的优化管线比 LLVM 短得多。LLVM 有几十个优化 pass,Cranelift 使用基于 e-graph 的中端优化器(代数简化、常量折叠、公共子表达式消除),配合 ISLE 规则驱动的指令选择和寄存器分配。但它不做循环变换、不做自动向量化、不做过程间分析。编译速度快的核心原因仍然是优化范围有限——用更少的 pass 换取更快的编译。
Cranelift 支持的目标架构包括 x86-64、AArch64、s390x 和 riscv64。对 Sprout 来说 x86-64 和 AArch64 就够了。
从 Sprout SSA 翻译到 CLIF
我们已经有了第 12 篇构造的 SSA 中间表示。从 Sprout SSA 到 CLIF 的翻译是比较直接的,因为两者都是 SSA 形式且都使用块参数。核心映射关系如下:
| Sprout SSA | CLIF |
|---|---|
Block |
block |
| 块参数 | 块参数 |
Const(i64) |
iconst.i64 |
Add(a, b) |
iadd |
Sub(a, b) |
isub |
Mul(a, b) |
imul |
Div(a, b) |
先检查除零,再 sdiv |
Lt(a, b) |
icmp slt + bint |
Jump(target, args) |
jump block(args) |
Branch(cond, then, else) |
brif cond, then, else |
Return(val) |
return val |
以第 09 篇的 sum_to 函数为例,翻译后的 CLIF 大致如下:
1 | |
结构和 Sprout SSA 几乎一模一样。块参数的传递方式完全对应,只是指令名称不同。这种高度对应关系是选择 Cranelift 的原因之一——翻译层可以很薄,不需要做复杂的变换。
Cranelift 提供的是一套构建器 API(FunctionBuilder),而不是文本格式解析。在 Rust 中调用方式如下:
1 | |
本篇使用 cranelift-codegen、cranelift-frontend、cranelift-jit、cranelift-module 版本 0.112(对应 Wasmtime 26)。Cranelift API 在大版本间有显著变化,请确保所有 crate 版本一致。
JIT 编译:从 CLIF 到函数指针
和 LLVM 后端最大的区别在于:Cranelift JIT 不产生目标文件,不需要链接器。调用 module.finalize_definitions() 之后,机器码已经写入当前进程的内存。通过 module.get_finalized_function(func_id) 拿到函数指针,直接用 Rust 的 unsafe FFI 调用:
1 | |
从 Sprout 源码到拿到可执行的函数指针,中间没有写文件、没有外部进程、没有链接步骤。整个过程在当前进程内完成,延迟在个位数毫秒级。
这个特性使得 Cranelift 特别适合实现 REPL(Read-Eval-Print Loop)。用户输入一个表达式或函数定义,编译器即时编译成机器码执行,把结果打印出来。交互体验和解释器一样快,但执行的是真正的本地机器码。
REPL 模式
基于 Cranelift JIT 实现一个简单的 REPL:
1 | |
REPL 的工作流程:
- 读取用户输入。如果是表达式,包装成
fn __repl_eval() -> i64 { return <expr>; }。 - 走完前端流水线:词法分析 → 语法分析 → 名字解析 → 类型检查 → SSA 构造。
- 把 SSA 翻译成 CLIF,用 Cranelift JIT 编译。
- 拿到函数指针,调用,打印返回值。
- 函数定义保留在符号表中,后续表达式可以引用之前定义的函数。
第 5 步需要注意:每次 JIT 编译的函数都在同一个 JITModule 中,所以之前编译的函数指针对后续编译的代码可见。函数之间的调用通过 module.declare_function 声明的符号名来解析。
性能对比
用 sum_to(10_000_000) 做一个粗略对比:
| 指标 | Cranelift JIT | LLVM O0 | LLVM O2 |
|---|---|---|---|
| 编译时间 | 3 ms | 45 ms | 120 ms |
| 运行时间 | 38 ms | 42 ms | 18 ms |
| 总时间 | 41 ms | 87 ms | 138 ms |
编译时间差异巨大:Cranelift 比 LLVM O2 快 40 倍。运行时间差距较小:Cranelift 生成的代码比 LLVM O2 慢约两倍,但和 LLVM O0 差距不大。
总时间(编译加运行)在这个规模上 Cranelift 胜出。但如果程序运行时间占主导(比如运行几秒的计算密集型程序),LLVM O2 的运行时优势会弥补编译时的投入。这就是为什么需要两个后端:短生命周期的执行用 Cranelift,长生命周期的发布构建用 LLVM。
这些数字来自一台普通笔记本电脑上的单次测量,仅供量级参考。第 22 篇讨论了更严谨的基准测试方法论。
后端抽象:一个 trait,两种实现
为了让前端不需要关心用的是哪个后端,把代码生成抽象成 trait:
1 | |
LLVM 后端实现这个 trait 时,compile_function 输出 LLVM 文本 IR 到文件,finalize 调用 opt + llc + cc 完成编译链接。Cranelift 后端的 compile_function 通过 FunctionBuilder 构造 CLIF 并 JIT 编译,finalize 调用 module.finalize_definitions()。
命令行接口通过标志选择后端:
1 | |
--jit 模式不生成文件,编译完直接执行 main 函数并以其返回值作为退出码。REPL 模式也隐含使用 Cranelift——没有人会为 REPL 中的每个表达式等待 LLVM 的完整编译流水线。
调试信息的差异
第 20 篇在 LLVM 后端添加了 DWARF 调试信息。Cranelift 同样支持调试信息生成,但 JIT 模式下的调试体验有所不同。LLVM 后端生成的是磁盘上的可执行文件,GDB 和 LLDB 可以直接加载它的 DWARF 信息。Cranelift JIT 的机器码在内存中,需要通过 JIT 调试接口(__jit_debug_register_code)将生成的机器码注册到 GDB,使调试器能够识别 JIT 编译的函数。
实际操作上,JIT 模式的调试更多依赖 REPL 本身的即时反馈,而不是传统调试器。遇到需要单步调试的复杂 bug 时,切换到 LLVM 后端生成完整的调试构建是更实际的做法。
练习
-
实现一个 REPL 原型:读入单个表达式(不含函数定义),包装成匿名函数,JIT 编译并打印结果。测试
1 + 2、sum_to(100)(假设sum_to已预编译),和一个类型错误的表达式。 -
对
fib(35)分别用 Cranelift JIT 和 LLVM O2 编译运行,记录编译时间和运行时间。分析总时间的交叉点:在什么计算量级下 LLVM O2 的总时间开始优于 Cranelift? -
阅读
cranelift-jit的JITModule文档,了解finalize_definitions之后如何处理后续新增的函数定义。如果用户在 REPL 中重新定义了一个已有的函数名,JITModule是否允许覆盖?如果不允许,你会如何设计函数重定义的语义?
