前 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 简单:整数类型有 i8i16i32i64,浮点有 f32f64,指针用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function %sum_to(i64) -> i64 {
block0(v0: i64):
v1 = iconst.i64 0 ; sum = 0
v2 = iconst.i64 1 ; i = 1
jump block1(v1, v2)

block1(v3: i64, v4: i64): ; v3=sum, v4=i
v5 = icmp sle v4, v0 ; i <= n
brif v5, block2, block3(v3)

block2:
v6 = iadd v3, v4 ; sum + i
v7 = iconst.i64 1
v8 = iadd v4, v7 ; i + 1
jump block1(v6, v8)

block3(v9: i64):
return v9
}

结构和 Sprout SSA 几乎一模一样。块参数的传递方式完全对应,只是指令名称不同。这种高度对应关系是选择 Cranelift 的原因之一——翻译层可以很薄,不需要做复杂的变换。

Cranelift 提供的是一套构建器 API(FunctionBuilder),而不是文本格式解析。在 Rust 中调用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use cranelift::prelude::*;
use cranelift_jit::{JITBuilder, JITModule};

let mut builder = JITBuilder::new(
cranelift_module::default_libcall_names()
)?;
let mut module = JITModule::new(builder);

let mut ctx = module.make_context();
let mut func_ctx = FunctionBuilderContext::new();

// 声明函数签名
let mut sig = module.make_signature();
sig.params.push(AbiParam::new(types::I64));
sig.returns.push(AbiParam::new(types::I64));

let func_id = module.declare_function(
"sum_to", Linkage::Export, &sig
)?;

ctx.func.signature = sig;
let mut builder = FunctionBuilder::new(
&mut ctx.func, &mut func_ctx
);

// 创建基本块、添加指令...
let block0 = builder.create_block();
builder.append_block_params_for_function_params(block0);
builder.switch_to_block(block0);
// ... 翻译每条 SSA 指令 ...

builder.finalize();
module.define_function(func_id, &mut ctx)?;
module.clear_context(&mut ctx);
module.finalize_definitions()?;

本篇使用 cranelift-codegencranelift-frontendcranelift-jitcranelift-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
2
3
4
5
6
7
let code_ptr = module.get_finalized_function(func_id);
let sum_to = unsafe {
std::mem::transmute::<*const u8, fn(i64) -> i64>(code_ptr)
};

let result = sum_to(10);
assert_eq!(result, 55);

从 Sprout 源码到拿到可执行的函数指针,中间没有写文件、没有外部进程、没有链接步骤。整个过程在当前进程内完成,延迟在个位数毫秒级。

这个特性使得 Cranelift 特别适合实现 REPL(Read-Eval-Print Loop)。用户输入一个表达式或函数定义,编译器即时编译成机器码执行,把结果打印出来。交互体验和解释器一样快,但执行的是真正的本地机器码。

REPL 模式

基于 Cranelift JIT 实现一个简单的 REPL:

1
2
3
4
5
6
7
sprout> 1 + 2
3
sprout> fn fib(n: i64) -> i64 { if n <= 1 { return n; } return fib(n-1) + fib(n-2); }
sprout> fib(10)
55
sprout> fib(30)
832040

REPL 的工作流程:

  1. 读取用户输入。如果是表达式,包装成 fn __repl_eval() -> i64 { return <expr>; }
  2. 走完前端流水线:词法分析 → 语法分析 → 名字解析 → 类型检查 → SSA 构造。
  3. 把 SSA 翻译成 CLIF,用 Cranelift JIT 编译。
  4. 拿到函数指针,调用,打印返回值。
  5. 函数定义保留在符号表中,后续表达式可以引用之前定义的函数。

第 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
2
3
4
5
6
7
8
9
pub trait Backend {
fn compile_function(
&mut self,
name: &str,
ssa: &SsaFunction,
) -> Result<(), CompileError>;

fn finalize(&mut self) -> Result<(), CompileError>;
}

LLVM 后端实现这个 trait 时,compile_function 输出 LLVM 文本 IR 到文件,finalize 调用 opt + llc + cc 完成编译链接。Cranelift 后端的 compile_function 通过 FunctionBuilder 构造 CLIF 并 JIT 编译,finalize 调用 module.finalize_definitions()

命令行接口通过标志选择后端:

1
2
3
sproutc build hello.spr -o hello     # 默认 LLVM,生成可执行文件
sproutc build hello.spr --jit # Cranelift JIT,直接运行
sproutc repl # REPL 模式,隐含 Cranelift

--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 后端生成完整的调试构建是更实际的做法。

练习

  1. 实现一个 REPL 原型:读入单个表达式(不含函数定义),包装成匿名函数,JIT 编译并打印结果。测试 1 + 2sum_to(100)(假设 sum_to 已预编译),和一个类型错误的表达式。

  2. fib(35) 分别用 Cranelift JIT 和 LLVM O2 编译运行,记录编译时间和运行时间。分析总时间的交叉点:在什么计算量级下 LLVM O2 的总时间开始优于 Cranelift?

  3. 阅读 cranelift-jitJITModule 文档,了解 finalize_definitions 之后如何处理后续新增的函数定义。如果用户在 REPL 中重新定义了一个已有的函数名,JITModule 是否允许覆盖?如果不允许,你会如何设计函数重定义的语义?


上一篇:25 - 从空目录构建并运行完整应用
下一篇:27 - 编译到 WebAssembly