SBCL: 汇编代码试验台
虚拟机 (VM) 设计与底层机器码优化之间的交集往往揭示了灵活性与性能之间的张力。在传统的基于栈的 VM 中,压栈 (push) 和出栈 (pop) 数据带来的开销——通常涉及内存访问或复杂的寄存器洗牌——可能成为显著的瓶颈。
在这项技术探索中,我们研究了一种通过利用 Steel Bank Common Lisp (SBCL) 汇编器来实现高性能基于栈的 VM 的方法。其目标是通过将一小组硬件寄存器视为旋转栈 (rotating stack),并为栈指针的每种可能位置专门生成机器码,从而消除栈操作期间的数据移动。
旋转栈概念
受 F18 和 x87 浮点单元的启发,核心思想是将 VM 栈限制在少量固定的槽位中(例如 8 个)。与其在 push 或 pop 期间在寄存器或内存之间物理移动数据,VM 会维护一个模块化计数器,指向当前的栈顶 (TOS)。
在软件实现中,动态索引寄存器通常是不可能的或速度极慢。为了解决这个问题,VM 采用了被称为原语特化 (primitive specialization) 的技术。对于每一个原语操作(如 ADD 或 DUP),VM 会生成八个不同版本的机器码——每个版本对应栈计数器的一个可能值。
寄存器映射
为了在 x86_64 中实现这一点,使用了以下寄存器分配:
r8到r15: 8 个栈槽位。rsi: 机器码原语的基地址。rdi: 虚拟指令指针 (VIP)。rax,rbx,rcx,rdx: 暂存寄存器。rsp: 虚拟返回栈指针。
使用 SBCL 实现 VM
SBCL 在本项目中充当“宏汇编器”。由于 SBCL 允许交互式地生成和检查机器码,因此编写能够根据虚拟栈指针的当前状态发射汇编代码的 Lisp 函数是可行的。
NEXT 序列
在直接线程化 (direct-threaded) VM 中,每个原语以一个 NEXT 序列结束,用于跳转到下一条指令。为了支持旋转栈,NEXT 序列必须分发到与新栈指针值相匹配的下一个原语的版本。
实现中将每个 primop 的变体存储在固定的间隔(例如 4288 字节)处。分发逻辑按如下方式计算偏移量:
variant_offset = 4288 * stack_counter
这使得 VM 能够直接跳转到针对当前栈深度的特化代码,而无需在运行时执行条件检查或复杂的查找表。
构建指令集
基础原语
简单的操作如 SWAP、DUP 和 ADD 是通过为当前映射到 TOS 和其下方元素的寄存器发射特定的 x86 指令来实现的。例如,一个 ADD 操作只需将 (@ 1) 处的寄存器与 (@ 0) 处的寄存器相加,并增加虚拟栈指针。
控制流
为了使 VM 具备功能性,控制流原语至关重要:
- JMP: 使用相对偏移量覆盖虚拟 IP。
- CALL/RET: 使用硬件栈 (
rsp) 来存储虚拟 IP 的返回地址。 - 条件判断 (JZ/JNZ): 使用
CMOV(条件移动) 或重复的NEXT序列,根据 TOS 的值来更新虚拟 IP。
性能分析与优化
该实现中最显著的发现之一是“融合”操作符的影响。一个简单的递减计数器并在非零时跳转的循环可以用几种方式实现:
- 未特化的字节码:
LIT$\rightarrow$SUB$\rightarrow$JNZ。这是最慢的方法(比原生代码慢 11-15 倍)。 - 融合操作符 (
DJN): 处理递减和跳转的单个原语。这显著提高了性能。 - 优化的融合操作符 (
DJN2): 通过将条件移动 (CMOV) 替换为可预测的硬件分支并重复NEXT序列,循环速度仅比原生汇编慢约 6 倍。
对比总结
| 实现方式 | 相对性能 (Cycles/Iter) |
|---|---|
| Native Assembly | 1x |
Specialized DJN2 |
~6x |
Specialized DJN |
~8x |
| Unspecialized Bytecode | 11-15x |
结论
只要栈的大小保持在较小范围内,基于虚拟栈指针对原语进行特化是一种减少线程化解释器开销的可行策略。虽然作者指出他们主要对栈语言不感兴趣,但这种架构可以作为一个高效的运行时中间表示 (IR)。
该项目突出了 SBCL 作为底层系统编程工具的强大之处。通过在高级 Lisp 抽象与原始机器码生成之间架起桥梁,SBCL 允许快速迭代和“试验”那些在 C 或传统汇编语言中会非常繁琐的汇编技术。