Rust 中的尾调用解释器 – Jimmy Ostler

Hacker News Top 工具

摘要

Jimmy Ostler 深入探讨了 Rust 中的尾调用解释器,实现并基准测试了多种虚拟机分发技术,包括 switch 分发、子例程线程化和尾调用优化的机器。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/10 11:33

# Rust 中的尾调用解释器 - Jimmy Ostler 来源:https://lordgoati.us/blog/tail-call/ Rust 中的尾调用解释器 - Jimmy Ostler --- 主页 (https://lordgoati.us/)文章 (https://lordgoati.us/blog/)Ternary (https://lordgoati.us/ternary/)Github (https://github.com/LordGoatius/)**2026 年 8 月 1 日**Jimmy Ostler字数:1636阅读时间:9 分钟 --- 最近,我在寻找改进我的`ternary` (https://lordgoati.us/ternary/)项目的方法时,偶然发现了`这篇` (https://noelwelsh.com/posts/understanding-vm-dispatch/)关于不同虚拟机调度风格的文章。我之前听说过尾调用解释,不过我最初的灵感来源花了一些时间才重新找到。不过,这篇文章对 Scala 中几种不同的虚拟机调度风格做了很好的剖析。我决定在 Rust 中实现它们(包括几种与我的项目更相关的变体)作为有趣的实验,并对其进行基准测试以衡量它们的差异。我会介绍 2 个版本——一个旨在模拟 Noel 的 Scala 代码,另一个则旨在利用 Rust 的优势构建一个更复杂、更传统的寄存器机。 ## 尾调用 尾调用解释是一种技术,它允许在编译时将某些递归转换为跳转,从而无需分配新的栈帧。这对于函数式语言(如 Scala)保持较小的栈空间极其有用,但大多数编译器都倾向于使用它。如果你想了解更多,我强烈推荐阅读上面 Noel 的精彩文章。在高优化级别下编译时,Rust 也会执行此优化,而不稳定的特性`explicit_tail_calls`允许我们直接告诉编译器执行该优化或报错。 ## 栈机(Noel 的机器) 我们能轻松处理的最简单的机器是一个包含 5 条指令的栈机,在 Rust 中表示如下: `` enum ByteCode { Lit(f64), Add, Sub, Mul, Div } `` 这与 Noel 的 Scala 代码基本相同。由于这是一个栈机,`Lit`(字面量)指令将值压入栈中;算术指令弹出操作数,并将结果值压回栈中。 ## 调度 作为对照组,switch 调度是最合理的。我们只需创建一个字节码数组,在一个`match`语句中循环遍历它并执行即可。 `` 注意:我决定使用一些奇怪的决策来与 Scala 保持一致。 包括使用 `static mut` 和 `unsafe`,而不是手动创建闭包, 尽管我在某种意义上也确实那么做了。我不赞成用这种方式编写 Rust。 `` ### Switch 调度 `` const STACK_SIZE: usize = 32; // 我们的栈 static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE]; // 要执行的指令列表 static mut INSTRS: &[Instr] = /* { [Lit(4.0), Lit(3.0)... etc] } */; // 我们将栈指针和指令指针传递给 `dispatch` pub fn dispatch(sp: usize, ip: usize) -> f32 { unsafe { if ip == INSTRS.len() { STACK[sp - 1] } else { match INSTRS[ip] { Instr::Lit(value) => { STACK[sp] = value; become dispatch(sp + 1, ip + 1) }, Instr::Add => { let a = STACK[sp - 2]; let b = STACK[sp - 1]; STACK[sp - 2] = a + b; become dispatch(sp - 1, ip + 1) }, Instr::Sub => { let a = STACK[sp - 2]; let b = STACK[sp - 1]; STACK[sp - 2] = a - b; become dispatch(sp - 1, ip + 1) }, Instr::Mul => { let a = STACK[sp - 2]; let b = STACK[sp - 1]; STACK[sp - 2] = a * b; become dispatch(sp - 1, ip + 1) }, Instr::Div => { let a = STACK[sp - 2]; let b = STACK[sp - 1]; STACK[sp - 2] = a / b; become dispatch(sp - 1, ip + 1) }, } } } } `` 这里我们可以看到整个逻辑——一个大型递归函数,每条指令都会调用自身。由于我们使用了`become` (https://github.com/rust-lang/rust/issues/112788)关键字,我们知道递归不会导致栈溢出。这是一个简单直观的策略!这里没有什么太复杂的。 ### 子例程调度 接下来我们进行子例程线程化,用其替换 match 语句。我们不再使用枚举,而是必须将指令实现为一个结构体,通过实现`Fn()` (https://doc.rust-lang.org/std/ops/trait.Fn.html)trait 使其可被调用。这意味着我们可以调用动态的`&dyn Fn()`,而无需关心底层结构体是什么。 我们的字节码现在看起来像这样(为简洁起见省略了部分内容): `` // 现在,我们的指令是 `&dyn Fn()`,因此我们可以使用动态分发 // 来调用不同的指令,而无需知道它们是什么。 static mut INSTRS: &[&dyn Fn() -> ()] = /*[&Lit, &Add... etc]*/; static mut SP: usize = 0; const STACK_SIZE: usize = 32; static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE]; struct Lit(f32); struct Add; struct Sub; struct Mul; struct Div; impl Fn<()> for Lit { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { STACK[SP] = self.0; SP += 1; } } } impl Fn<()> for Add { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a + b; SP -= 1; } } } impl Fn<()> for Sub { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a - b; SP -= 1; } } } impl Fn<()> for Mul { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a * b; SP -= 1; } } } impl Fn<()> for Div { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a * b; SP -= 1; } } } pub fn dispatch(ip: usize) -> f32 { unsafe { if ip == INSTRS.len() { STACK[SP - 1] } else { INSTRS[ip](); become dispatch(ip + 1) } } } `` 当然,我可以移除全局变量,要么将它们作为字节码数据的一部分,要么将它们作为变量传递。一个相当简单的技巧是让每个函数传递并返回所有必要的值。这种技术同样受益于尾调用优化,因此我们不必担心函数传递的开销。此外,我们可以使用普通函数并增加一个稍微复杂的解码阶段,但对于*这部分*,我希望尽可能与 Scala 版本保持一致。 ### 间接调度 接下来,Noel 讨论了间接线程化。这种技术保留了 match 语句,但我们不是直接返回并通过递归循环。相反,我们使用*间接递归*。这意味着操作调用 dispatch 函数,而不是返回后由函数自身调用自己。 理论上,这会少一次函数返回,并允许函数调用开销被尾调用优化消除。在 Rust 中,这看起来像这样。 `` pub enum ByteCode { Lit(f32), Add, Sub, Mul, Div } static INSTRS: &[ByteCode] = ...; static mut SP: usize = 0; static mut IP: usize = 0; const STACK_SIZE: usize = 32; static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE]; pub fn dispatch(instr: ByteCode) -> f32 { match instr { ByteCode::Lit(val) => lit(val), ByteCode::Add => add(), ByteCode::Sub => sub(), ByteCode::Mul => mul(), ByteCode::Div => div(), } } fn lit(val: f32) -> f32 { unsafe { STACK[SP] = val; SP += 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { dispatch(INSTRS[IP]) } } } fn add() -> f32 { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a + b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { dispatch(INSTRS[IP]) } } } fn sub() -> f32 { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a - b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { dispatch(INSTRS[IP]) } } } fn mul() -> f32 { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP * 2] = a - b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { dispatch(INSTRS[IP]) } } } fn div() -> f32 { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP * 2] = a / b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { dispatch(INSTRS[IP]) } } } `` 这里我们还利用 return 返回最终值。到目前为止,我最喜欢这一个,部分原因是它避免了我在子例程线程化中使用的更复杂的虚分发。这主要是因为我以 Rust`Fn()`trait 并不真正适合的方式使用了它们,因为允许这样使用它们的特性仍然不稳定。 这引出了最后一个问题。如果我们组合这些技术会怎样?我们从操作内部进行调度,但使用一个`dyn Fn()`数组。这意味着几个好处:每条指令一次函数调用,并且没有 match 语句。虽然情况各不相同,但这在某些微架构的某些方面可能更友好,同时函数调用次数最少,为 1(这取决于你的虚拟机架构,但重要的是每个操作直接调用下一个操作)。 ### 直接调度 这就引出了直接调度。我们暂时回到将对象作为字节码(for now),让每个操作调度下一个操作。结果在我的机器上成为了性能最佳的变体。它看起来大致像这样: `` static mut INSTRS: &[&dyn Op] = ...; static mut SP: usize = 0; static mut IP: usize = 0; const STACK_SIZE: usize = 32; static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE]; pub trait Op: Fn() -> f32 {} impl Op for Lit {} impl Op for Sub {} impl Op for Add {} impl Op for Mul {} impl Op for Div {} struct Lit(f32); struct Add; struct Sub; struct Mul; struct Div; impl Fn<()> for Lit { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { STACK[SP] = self.0; SP += 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { INSTRS[IP]() } } } } impl Fn<()> for Add { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a + b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { INSTRS[IP]() } } } } impl Fn<()> for Sub { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a - b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { INSTRS[IP]() } } } } impl Fn<()> for Mul { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a * b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { INSTRS[IP]() } } } } impl Fn<()> for Div { extern "rust-call" fn call(&self, _args: ()) -> Self::Output { unsafe { let a = STACK[SP - 1]; let b = STACK[SP - 2]; STACK[SP - 2] = a * b; SP -= 1; IP += 1; if IP == INSTRS.len() { STACK[SP - 1] } else { INSTRS[IP]() } } } } `` ## 结果 这种技术完全没有独立的`dispatch`函数,这意味着要开始计算,你只需调用指令指针位置处的指令即可。所有这些实验在我的机器上得出了以下结果: `` test bench::direct ... bench: 24.59 ns/iter (+/- 0.50) test bench::indirect ... bench: 55.32 ns/iter (+/- 1.21) test bench::subroutine ... bench: 79.93 ns/iter (+/- 1.18) test bench::switch ... bench: 59.39 ns/iter (+/- 2.16) `` 直接调度是明显的赢家,这并不太令人惊讶,因为它充分利用了尾调用的能力,同时不像其他递归技术那样做那么多额外工作。间接调度似乎有大约 2 倍的开销,这意味着多一次函数调用的开销并不一定很小。不过我们在这里需要小心——这些数字非常小,我们应该警惕编译器所做的优化,尤其是(在这种情况下)它完全掌握了我们打算执行的指令信息。尽管如此,这些技术的数字似乎与我们预期的一致,而且它们之间的相互关系在现代硬件上似乎也反映了这一点。 ## 进一步实验 虽然这些技术直接从 Scala 翻译成某种非正统的 Rust 代码非常酷,但它们并不是我的 ternary VM 应该采用的风格。首先,我的 VM 往往更像真实硬件,因为我的目标是模拟某种假想的理论三进制硬件,这意味着需要更复杂的解码步骤和寄存器。因此,我创建了一个极其有限的小型 16 位寄存器机,以便在更接近我最终用例的条件下进行测试。 该机器很简单: `` pub struct Machine { regs: [u16; 16], instrs: [Instr; 256], ip: usize, } pub enum Op { Halt = 0b0000, Add = 0b0001, Sub = 0b0010, Mul = 0b0011, Div = 0b0100, Bgt = 0b0101, Bleq = 0b0110, } `` `` 指令编码: 12 8 4 0 ┌─────┬────┬────┬────┐ │ IMM │ R1 │ RD │ OP │ └─────┴────┴────┴────┘ `` 这不是世界上最革命性的东西,但足以测试一个简单的寄存器机。 ### Switch 调度 Switch 调度与预期差不多,只是包含一个更大的解码阶段。此外,我们将指令指针内化到机器中,避免了上一阶段不幸的`static mut`。我们也可以将指令指针作为递归参数内化到`run`函数中,但在这种情况下这不太可能产生影响(这是我目前在我的 ternary VM 中使用的方法,不过在我最终确定之前可能应该做更多测试)。 `` pub fn run(machine: &mut Machine) { loop { let instr = machine.instrs[machine.ip]; let rd = instr.rd() as usize; let r1 = instr.r1() as usize; let rdv = machine.regs[rd];

相似文章

C语言中的尾调用优化相对较新

Hacker News Top

LWN的一条评论讨论了C语言编译器中尾调用优化相对较新的实现,引用了历史上的局限性,并指出现代的GCC和Clang现已支持该优化,这对解释器实现有潜在好处。

来自未来的 RISC-V 解释器

Lobsters Hottest

Rust 编写的 RISC-V 解释器的更新,支持模块化、no_std、编译时执行,并严格符合规范,同时利用了众多 Rust nightly 版本的特性。

Rust类型系统中的Lisp

Hacker News Top

一个嵌入在Rust trait系统中的Lisp解释器,支持在编译时进行递归函数、闭包和延续传递风格。

突破 RISC-V 模拟的极限

Hacker News Top

这篇博客文章探讨了如何通过在非 RISC-V 机器上使用提前重编译器来加速 RISC-V 执行,该重编译器通过尾调用连接基本块,并利用 Clang 的 preserve_none 调用约定,作为 Axiom 的 OpenVM 项目的一部分。

Rust 函数重载:实验性呼吁

Lobsters Hottest

Rust 项目正在 nightly Rust 构建中实验函数重载,以通过新的 #[rustc_splat] 属性增强 FFI 绑定,特别是与 C++ 的互操作性,提供更人性化的调用语法。