此文为翻译,原文在此
首发于,知乎专栏:Ruby+OMR JIT 简介
利益相关: 《Ruby 源码剖析》现已上市!
前不久,我开源了 Ruby+OMR JIT 编译器这篇文章中提到的 Ruby JIT glue ,为此我深感荣幸。
该代码(适度修改过的 CRuby VM 版本和Eclipse OMR的组合)展示了我们如何向 CRuby 添加一个简单的 JIT 编译器。
本文会涉及我在 2015 年 12 月 RubyKaigi ( Video , Slides ) 的演讲内容,但是这次我将展示源码。
该文专注于 JIT 技术,然而我们在 GitHub 上的 VM 已经被修改为使用 OMR 的垃圾收集技术。有关更多的内容,请参阅 Craig Lehman 和 Robert Young 在 RubyKaigi 上所做的演讲,It's dangerous to GC alone. Take this!。
OMR 项目基于一个前提:即许多语言运行时实现基本上是非常相似的,哪怕是完全无法类比的语言。
该理念决定了 OMR 被构造为语言独立的大核心,通过依赖于语言的“胶水(glue)”,链接到实际的语言实现中。
对于 Ruby JIT 来说,我们的“胶水”分为两个部分:
我们尽最大的努力尽量最小化第一部分,因为我们想展示一个相对低触摸(low-touch)的 JIT 添加。
我们一直希望 Ruby 社区最终可以采用 OMR JIT 作为 Ruby 的 JIT 技术,所以尽量使 VM 保持不变很重要,没有人喜欢维护这些像异形一样的补丁。这个决定限制了编译器技术的某些部分,但是我们可以跳过它(有些东西我们需要独立开发!)
在运行 JIT 代码之前,我们需要在 VM 和 JIT 编译器之间提供一个接口。
在我们的实现中,我们在 Init_BareVM 中增加了 vm_jit_init 调用。该函数(实现于 vm_jit.c 中)加载 JIT DLL 并且填充一组函数指针的结构。通过这些函数指针来建立 JIT 和 VM 的通信,VM 可以使用这些函数指针(init_f, terminate_f, compile_f 和 dispatch_f)来调用 JIT 方法。同样,JIT 也能调用 VM 方法,或者通过使用存储于 JIT 回调结构中的函数指针来生成对 VM 方法的调用。稍后我们会再看这个回调结构。
另一个重要的连接是 Ruby VM 使用的全局变量。我们需要将这些地址传递给 Ruby JIT,这份工作是由全局结构体来完成的,该结构体被填充以后就传递给 vm_jit_init。
何时编译方法,即为编译控制。
生产 JIT 编译器有非常复杂的试探式方法来管理启动速度,提升和峰值吞吐量之间的权衡。
在 Ruby JIT 中,我们做了一个更为简单的选择:计算编译(counted compilation)。每个方法都有与之相关的计数器。方法运行时,计数器递减,到达 0 值时,该方法被编译完成。
编译控制的注入与 VM 调用 JIT 编译方法方式紧密的结合了起来。
OMR Ruby JIT 对 JIT 方法采用了一个非常简单的调用约定。JIT 生成的方法只需要一个参数:
typedef VALUE (*jit_method_t)(rb_thread_t*);
方法的所有其他信息都从编译代码的线程参数中去检索。
Ruby VM 的核心解释器循环有一些轻微修改,以便支持调用这些编译体。
一般来说,解释器通过调用vm_exec_core来启动核心解释器循环调用。
为了让我们检查是否可以使用 JIT 编译体,我们把该函数聪(中)明(二)的命名替换为了vm_exec2。
vm_exec2非常简单:
static inline VALUE
vm_exec2(rb_thread_t *th, VALUE initial)
{
VALUE result;
if (VM_FRAME_TYPE(th->cfp) != VM_FRAME_MAGIC_RESCUE &&
VM_FRAME_TYPE_FINISH_P(th->cfp) &&
vm_jitted_p(th, th->cfp->iseq) == Qtrue) {
result = vm_exec_jitted(th);
} else {
result = vm_exec_core(th, initial);
}
return result;
}
该代码简单归结为两点:
要想把 OMR JIT 编译器添加到语言中,最基本的部分是如何将该语言的源码转换为 JIT 编译器的中间表示(IR, Intermediate Representation)。
Testarossa 的中间表示叫做树(虽然它们实际上叫 Directed-Acyclic-Graphs【有向无环图】,可惜历史的能量太强)。该树中的程序展示包含了三个主要元素:
所以,为了 JIT 编译 Ruby,我们不得不把下面的代码:
def foo(a, b)
a + b
end
转换到树中。在 Testarossa 中,我们把这个过程称作 IL 生成。
幸运的是,我们不必处理 Ruby 代码的解析。CRuby 解释器已经为我们做了这部分工作。自 Ruby1.9 开始,Ruby 就一直使用基于字节码的解释器,因此 Ruby 代码是被解析为字节码再进行解释。你可以使用 RubyVM :: InstructionSequence.dump 方法查看字节码:
$cat test.rb
def foo(a, b)
a + b
end
$ ruby -e "puts RubyVM::InstructionSequence.compile_file('test.rb').disasm"
# <snipped iseq for main>
<RubyVM::InstructionSequence:[email protected]>=======================
local table (size: 3, argc: 2 [opts: 0, rest: -1, post: 0, block: -1] s1)
[ 3] a<Arg> [ 2] b<Arg>
0000 trace 8 ( 1)
0002 trace 1 ( 2)
0004 getlocal_OP__WC__0 3
0006 getlocal_OP__WC__0 2
0008 opt_plus <callinfo!mid:+, argc:1, ARGS_SKIP>
0010 trace 16 ( 3)
0012 leave ( 2)
所以,要生成 Ruby 的树,我们可以解析方法产生的字节码,而不是直接解析源码。在 rbjitglue 中,这部分工作由RubyIlGenerator.cpp文件来完成。
Ruby 所遵循的 Testarossa 的基本字节码 IL 生成方案假定每个字节码对应至多一个基本块(block)。因此,基本的块数组会被创建,然后沿着字节码经历“抽象解释过程”,生成树,再放到基本块中。抽象解释的意思是,把 IL 生成器生成代码的过程比作解释器。
当检测到字节码级别的跳转(jump)时,相应的目的地字节码的树级跳转的基本块也会被生成。
大部分工作最终是在RubyIlGenerator :: indexedWalker内部完成的。其核心,是一个巨大的switch 语句:
switch (insn)
{
case BIN(nop): /*nothing */ _bcIndex += len; break;
// elided
case BIN(getlocal): push(getlocal(getOperand(1)/*idx*/, getOperand(2)/*level*/)); _bcIndex += len; break;
// elided
case BIN(leave): _bcIndex = genReturn(pop()); break;
对于每个 YARV 字节码,我们将在抽象操作中再现执行时发生的操作。例如,YARV 的操作码 getlocal 把局部区域的值压到堆栈上,因此,这个抽象版生成的代码将从局部区域加载一个值,然后将其压到抽象的堆栈上。其他操作数将消耗此抽象堆栈上的值,比如该例中的 leave。
最后,我们得到了树!让我们看看上面 foo 示例中 getlocal 和 opt_plus 操作得到的树。
n23n treetop
n22n lloadi a[#612 a -24]
n21n aloadi ep[#599 ep +48]
n20n aloadi cfp[#600 cfp +48]
n19n aload <parm 0 Lrb_thread_t;>[#598 Parm]
n28n treetop
n27n lloadi b[#613 b -16]
n26n aloadi ep[#599 ep +48]
n25n aloadi cfp[#600 cfp +48]
n24n aload <parm 0 Lrb_thread_t;>[#598 Parm]
n35n astorei pc[#603 pc]
n34n aloadi cfp[#600 cfp +48]
n33n aload <parm 0 Lrb_thread_t;>[#598 Parm]
n32n aconst 0x5606fbdad740
n36n treetop
n31n lcall vm_opt_plus[#290 helper Method]
n30n aload <parm 0 Lrb_thread_t;>[#598 Parm]
n29n aconst 0x5606fbdad7a0
n22n ==>lloadi
n27n ==>lloadi
为了讲清楚,我将去除一些标志和节点元数据来简化。你可以通过使用 traceFull,log=LOGFILENAME 生成 log 来查看完整输出。
纵使被简化内容也不少!
前面说过,我会讨论jit_callback_struct更多的细节。它的基本形式,看起来就像这样:
struct jit_callbacks_struct {
/* Include generated callbacks. */
#include "vm_jit_callbacks.inc"
/* ... */
const char * (*rb_id2name_f) (ID id);
// ...
}
之所以用#include 是因为,我们的回调的大部分实际上是在构建期间被生成的,而非手工创建。我们能这样做是得益于 YARV 的聪明设计。
YARV 使用名为insns.def的“指令定义文件”,用于生成核心解释器循环。但是,我们也可以利用该定义文件自动创建回调。在我们修改的 RubyVM 构建过程中,我们使用相同的处理代码创建解释器循环,并自动发出回调和函数指针声明。
所以,一个指令定义看起来像这篇博客定义的这样:
/**
@c put
@e to_str
@j to_str の結果をスタックにプッシュする。
*/
DEFINE_INSN
tostring
() /* Instruction operands -- these are in the instruction seqeuence */
(VALUE val) /* Values popped from the operand stack */
(VALUE val) /* Return value */
{
val = rb_obj_as_string(val);
}
我们生成的回调(至于 vm_jit.inc)看起来像这样:
VALUE
vm_tostring_jit(VALUE val)
{
val = rb_obj_as_string(val);
return val;
}
生成回调允许我们支持大量的 Ruby 操作码,而不必手工编写大量的 ILGen 代码。但是有一个权衡:就像 Evan Phoenix 在他 2015 年主题演讲 RubyKaigi 中提到的,这种风格的 JIT 有优化限制,然而,它非常符合我们的 Ruby JIT 哲学。
本文不会涉及太多代码生成。因为它并非是 Ruby 特定的,而且也会牵扯其他方面。但是编译的最终产品是一个 startPC,它对应于为方法生成代码的开始。它将会在 vm_exec_jitted 被调用,如上所述。
啧啧啧——你竟然看完了!非常感谢阅读!本文是对 Ruby+OMR JIT 不太简短的总结。现在,它只适用于 Ruby2.1.5,然而,我们将逐步支持到 Ruby2.4。
这里是一份仍然需要为 Ruby JIT 构建的任务列表:
我很想听听你对我们迄今为止进展的看法。请在本(原)文下面发表评论,通过邮件列表发送一条见解,或者在 GitHub 上提交一个issue。