纯解释器(无 JIT)怎么榨干性能?把一个递归测试从几秒压到几百毫秒,本质上就是和内存拷贝、指令分发以及 CPU 的分支预测器死磕。这里介绍一下Fig使用的,现代 VM 架构中把 Call 压榨到极限的几个底层实现。
1. 寄存器滑窗 (Register Sliding Window) 传统的栈机或者朴素的寄存器机,函数调用最蠢的一步就是传参:把数据从 Caller 的作用域拷出来,再压进 Callee 的局部变量表。在几十万次的深层递归里,VM 大量的时间全在做毫无意义的内存搬运。
解法是物理上的寄存器滑窗。VM 只需要维护一个预分配的超大 Value 数组。在编译期,让 Caller 把参数直接写在连续的寄存器槽里(比如基于当前帧偏移的 baseReg + 1, baseReg + 2)。当 Call 指令触发时,不发生任何数据拷贝,VM 只做一次指针加法:把当前栈帧的基址指针 registerBase 向前推 baseReg 个身位。
此时,Caller 眼里的 R[baseReg] 原地变成了 Callee 眼里的 R[0],传参的时间复杂度从 O(N) 被砍成了 O(1) 的指针偏移。
2. 绕开对象系统:静态去虚化 (FastCall) 动态语言,如果”一切皆对象”,每次调用都要从 FunctionObject 里解包提取字节码原型(Proto),这种内存间接寻址对 L1 Cache 极不友好。
对于在编译期就能确定的顶层函数或内部调用,直接在 Compiler 层进行去虚化(Devirtualization)。把函数拍平到一个全局连续的 Proto* 数组里,分配一个静态的 ProtoIdx。生成的字节码不再走动态解包,而是直接发射 FastCall,把 ProtoIdx 硬编码在指令的操作数里。
运行时 VM 读取到 FastCall,直接通过 O(1) 的数组下标拿到目标指令流,顺手把滑窗指针推过去。没有任何动态对象分配,也不查符号表,开销极小。
3. Computed Goto (Direct Threaded Code) 当传参和解包开销被滑窗和 FastCall 榨干后,VM 最后的物理瓶颈只剩下 Instruction Dispatch(指令分发)。
朴素的 while(true) { switch(opcode) } 在底层是一个巨大的单点间接跳转。所有指令执行完都要跳回这个 switch 头部。CPU 的分支预测器在这里直接变成瞎子——它根本猜不出 Add 后面跟着的是 Mov 还是 FastCall。预测失败导致流水线频繁冲刷(Pipeline Flush)。
扔掉 switch,利用 GCC/Clang 的非标准扩展(Labels as Values),建一个绝对地址的静态跳转表 void* dispatchTable[] = { &&do_Exit, &&do_Load... }。在每条指令 C++ 逻辑的末尾,直接取下一条指令的 OpCode,查表并 goto。
这一步改造后,每种指令在汇编层都拥有了独立的跳转节点,CPU 终于能根据上下文历史建立正确的分支预测。单靠这一手,能把整个解释器的 MIPS(每秒百万指令数)再硬拔 20% 到 30%。
如果你发现速度没有什么改变,那大概率是编译器在o2/o3下自动优化成computed goto了,但这不稳定。
NaN-Boxing 保证内存紧凑度,滑窗干掉拷贝,FastCall 绕开对象开销,最后 Computed Goto 喂饱 CPU 流水线。把这套打通,不用写一行汇编和 JIT,纯解释器也能跑到几百 MIPS 的工业级水位。
部分信息可能已经过时