Scheme 语言对 “恰当的尾调用”(Proper Tail Calls)有着严格的要求,这意味着处于尾位置的函数调用必须被优化为跳转而非新建栈帧,从而避免递归深度增加导致的栈溢出问题。Hoot 是 Guile Scheme 编译到 WebAssembly 的编译器,它面临一个独特的挑战:WebAssembly 原生支持尾调用指令(如 return_call),但其有限的栈空间和严格的类型系统使得实现非尾调用(Push Calls)变得复杂。Hoot 采用了一种工程化的解决方案:通过后期的 CPS(Continuation-Passing Style,续体传递风格)转换,将程序中的所有调用统一为尾调用,并通过显式的三栈架构管理栈帧数据。本文将深入分析这一实现背后的具体策略与工程细节。
一、CPS 转换与 Tails 分割机制
在传统的直接风格(Direct Style)程序中,函数调用分为尾调用和非尾调用。非尾调用需要暂停当前函数的执行,将其剩余计算(即 “续体”)压入某种形式的栈中,待被调用函数返回后恢复执行。Hoot 的核心策略是在编译的后期阶段引入 CPS 转换,将这种隐式的栈管理显式化。
具体来说,Hoot 会扫描每个函数,在每个非尾调用(Push Call)点将函数 “分割” 成多个部分,我们将其称为 “Tails” 或 “续体”。例如,对于函数 f,如果在中间有一个非尾调用 g,那么 f 会被切分为 f_entry(调用 g 之前的部分)和 f_after(调用 g 返回后执行的部分)。f_after 本身也变成了一个可被调用的过程,它接收 g 的返回值作为参数,并在此处进行尾调用后续逻辑。
这种分割是递归的:如果函数中有连续的两个非尾调用,那么它会被切分为三个部分。控制流的合并(如 if 分支汇合处)也会引入新的分割点。最终,转换后的程序结构变成了一组以尾调用链接起来的 “续体” 图。每个续体在逻辑上代表原函数的一个执行片段,而片段之间的跳转则完全依赖于 WebAssembly 的尾调用指令。Hoot 的实现细节表明,这种转换不仅解决了栈空间限制,还为实现 delimited continuations(定界续体)奠定了基础,因为它本质上将续体数据暴露在了栈上。
二、三栈架构:应对 WebAssembly 类型系统的工程妥协
WebAssembly 的类型系统与 Scheme 存在显著差异。Scheme 是一种动态类型语言,其值(如 SCM)通常是统一的引用类型。然而,在编译器内部,为了优化性能,Hoot 经常会将值拆箱(unbox)为原始的 u64、s64 或 f64。更棘手的是,WebAssembly 没有 “或类型”(Sum Type),即不存在一个槽位可以存储一个 i32 或一个对象引用 (ref eq)。
为了解决这个问题,Hoot 工程师设计了一套令人印象深刻的 “三栈” 架构:
- 数值栈(Numeric Stack):用于存储原始的数值类型(
i32,i64,f32,f64等)。由于 WebAssembly 的线性内存是存储原始数值的最佳选择,数值栈实际上是一个在 Wasm 内存中分配的缓冲区。 - 引用栈(Reference Stack):用于存储
(ref eq)类型的引用。WebAssembly 的 Table 通常用于存储这类引用,因此引用栈被实现为一个 Wasm Table。 - 续体栈(Continuation Stack):用于存储函数的返回续体。这与其他数据有本质区别,因为续体本质上是一个函数引用。WebAssembly 的函数类型层次结构(func)与
eq类型不相交,因此需要一个独立的存储空间,通常是另一个专门的 Table。
在生成非尾调用的代码前,Hoot 会进行活跃变量分析(Live Variable Analysis)。只有那些在调用返回后仍会被用到的局部变量,才会被 save! 到上述对应的栈中。这一步至关重要,它避免了保存不必要的临时变量,减少了内存开销。值得注意的是,常量通常不会被保存,而是在后续的 Tail 中重新具体化(Reify),从而节省了宝贵的栈空间。
三、指令生成策略与工具链建设
Hoot 的 CPS 转换生成了一组没有副作用的 “纯” 续体函数。这些函数的调用约定(Calling Convention)是高度定制化的:返回续体接收被调用函数的返回值作为参数,并负责 pop! 之前保存的上下文,然后执行真正的逻辑尾调用。
在 WebAssembly 指令层面,Hoot 充分利用了原生的 return_call 和 return_call_indirect 指令。return_call 用于调用已知的目标函数,而 return_call_indirect 则用于调用动态派发的续体(通常是通过索引从 Continuation Table 中获取)。
值得注意的是,为了精细控制 Wasm 输出,Hoot 投入了大量精力构建了一个约 10K 行代码的自研 Wasm 工具链。这包括 Wasm 二进制 / 文本格式的解析器、验证器和发射器。为什么不直接使用 Binaryen?Hoot 的作者指出,部分高级特性(如 WasmGC 提案中的某些特性或早期的异常处理提案)在 Binaryen 等通用工具链中的支持往往滞后或不完整。此外,自研工具链使得 Hoot 能够实现 %inline-wasm 这样的强大宏,允许在 Scheme 代码中内联嵌入经过类型检查的 Wasm 代码片段,极大提升了互操作性和运行时性能。
四、工程挑战:栈帧复用与优化空间
尽管方案在功能上已相当完备,但 Hoot 的作者也坦诚地指出了目前的不足。栈帧复用并非总是高效的。例如,考虑一个简单的函数:
(define (q x)
(define y (f))
(define z (f))
(+ x y z))
Hoot 的 CPS 转换可能会生成一个低效的序列:q1 恢复了 x,但目的仅是为了再次 save! 以供 q2 使用。在严格遵守栈纪律(Stack Discipline)的情况下,x 应该只在初始化时保存一次。这需要更复杂的流分析(Flow Analysis)来识别这种冗余的 Save/Restore 操作。目前,Hoot 尚未完全解决这个问题,这导致了某些基准测试中高达 10 倍的性能惩罚,尽管另一些场景下性能反而优于原生 Guile。
然而,对于 Hoot 而言,当前的性能是可接受的,因为它解锁了更重要的能力:在浏览器中运行完整的 Guile Fibers(轻量级线程),并能优雅地与 JavaScript 的 Promise 机制集成,而无需依赖 JSPI 或 Asyncify 等非标准的黑科技。这是在工程上对功能性与性能的一次成功权衡。
资料来源:本文核心实现细节参考自 Hoot 开发者 Andy Wingo 的博客文章《cps in hoot》与《hoot's wasm toolkit》。