造成Ruby和JRuby执行性能上存在差异的原因是什么?

业余程序员 【虚拟机启动时间不在讨论范围】 如果说JRuby比Ruby执行效率高,究竟是什么原因造成的呢? Ruby是用C实现的,JRuby是用Java,按理说应该是Ruby更快一些,但是很多benchmark都表明JRuby更快。 这里说的JRuby主要指新的9000系列 Ruby Benchmarks Report
关注者
131
被浏览
2743
其实道理很简单:因为实现方式不一样。

如果CRuby和JRuby都是用完全一样的思路来实现的,例如说如果用解释器来实现执行引擎的话双方都这么做,差别只是一个是用C写的另一个是用Java写的,那么比的就是C语言与Java语言的实现的速度。这个时候只要写C的程序员不傻,写出比Java版快的程序那是很自然的。

但您对比的对象却不是这样。

CRuby,或者叫MRI,一直是用解释器的方式来实现执行的。直到1.8.x的时候它还是用遍历语法树的方式来解释执行;从1.9开始VM换成YARV,改为用字节码解释器的方式解释执行,速度已经比之前的MRI快了不少。

而JRuby从很早的时候开始就是用“编译”的方式实现Ruby代码的执行的。
以前的JRuby内部没有解释器,在遇到Ruby代码时总是将其编译为Java字节码交给JVM执行。底下的JVM内部可能有解释器也可能没有,但高性能的JVM都有JIT编译器,会进一步将Java字节码编译到机器码。这么一来,在一个有JIT编译器的JVM上运行的JRuby就等于是一个有JIT编译器的Ruby实现了。这样可以看出差异了么?

放个老文传送门,以前回答过类似的问题:YARV和JIT,还有JRuby……

其实CRuby(YARV之后)里也有字节码编译器——把Ruby源码编译到YARV字节码,而JRuby的也算是字节码编译器——把Ruby源码编译到Java字节码;只是后续执行这个字节码的方式有差异就是了。JRuby可以依赖JVM的JIT来实现到机器码的编译,而YARV自己只实现了比较简单的解释器来解释执行。

新的JRuby 9000系列则是进一步改进了执行流程,添加了一个用Java写的Ruby解释器以便降低启动时的开销,提升启动性能;而其编译器也改用了新的架构,有很大的进一步实现编译优化的潜力。JRuby 9000的编译器比YARV的优化程度又要更高一些。
传送门:

用C或C++实现的Ruby并非没有带JIT的版本。
例如说IBM给CRuby加了一个JIT:如何评价 IBM 的 Ruby + OMR? - RednaxelaFX 的回答
又例如说Miura Hideki大大给mruby做了一个带JIT的分支:miura1729/mruby: Lightweight Ruby with JIT compiler
又例如说Rubinius自带基于LLVM实现的JIT编译器(可惜使用LLVM的方法不太好所以并未发挥出LLVM的完整潜力):Rubinius
要找对对手来比性能才行 ^_^

还有基于PyPy框架实现的Topaz:Welcome to Topaz <- 这个是用RPython实现的

另外,JRuby默认是不使用GVL(Giant VM Lock)的,而CRuby直到最新的2.4.0仍然需要使用GVL。这无论对单线程还是多线程Ruby程序都可能带来性能影响——就算是单线程的Ruby程序,CRuby的VM也会定期去检查和处理GVL的状态变化,而不使用GVL的话就完全没有这种问题,细粒度锁只会在具体事件发生的时候才会去检查状态变化。
JRuby为了兼容某些为CRuby而写的C扩展,还有一个“安全模式”,其中会使用GVL。

再补充一个有趣的点。为动态类型语言优化性能的时候,一种很重要的优化是“box elimination”(消除装箱)。以Ruby的Fixnum为例,其实Fixnum在CRuby里是一种boxed integer类型——它并不是一个单纯的整数,而是一个整数包装在一个最低位带着tag的容器(box)里。
许多人都知道Fixnum是一种“带标记的整数”(tagged integer),是一种“立即对象”(immediate object)——对象的内容直接藏在指针里,而不是在堆上——但是正确理解这也是一种“装箱”(box)的人或许就没那么多了。

这就使得许多简单的操作都需要有条件跳转,例如:
def add(a, b)
  a + b
end
它在Ruby 2.0.0的YARV里对应的字节码是:
== disasm: <RubyVM::InstructionSequence:foo@<compiled>>=================
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
0004 getlocal_OP__WC__0 3
0006 getlocal_OP__WC__0 2
0008 opt_plus         <callinfo!mid:+, argc:1, ARGS_SKIP>
0010 trace            16
0012 leave

其中对于opt_plus字节码的实现,其主要路径需要经过这样的操作(类C伪代码):
  if (a is Fixnum && b is Fixnum && !redefined?(Fixnum, :+)) {
    int a_int = FIX2LONG(a); // long这里其实跟int一样宽所以我就写回成int了
    int b_int = FIX2LONG(b);
    int c_int = a_int + b_int;
    if (c_int can be Fixnum) {
      Fixnum c = LONG2FIX(c_int);
      return c;
    } else {
      // ...
    }
  } else {
    // ...
  }
具体实现在这里:ruby/insns.def at 645d23955fba6fcf41abc07ab08e7a1b69fc22dc · ruby/ruby · GitHub
这么简单的加法,实际实现却得经过
  • 判断操作数的类型是否都为Fixnum,以及Fixnum#+是否有被重定义
  • 拆箱(unboxing):FIX2LONG
  • 实际加法运算
  • 判断运算结果是否在Fixnum范围内
  • 装箱(boxing):LONG2FIX
这5步操作。

如果在编译器里实现了“消除装箱”的操作,那么上面的运算流程就可以简化为:
  • 直接在两个int(或long)上做实际加法运算
而不需要做额外的类型判断。Fixnum#+是否有被重定义也可以通过注册依赖关系(dependency tracking)与去优化(deoptimization)结合来优化掉。

JRuby 9000的编译器就实现了消除装箱的优化,可以把不需要装箱的、可以推导出类型为Fixnum的局部变量直接生成为JVM能识别的long整数类型:
jruby/UnboxingPass.java at master · jruby/jruby · GitHub
jruby/UnboxableOpsAnalysisNode.java at master · jruby/jruby · GitHub
jruby/JVMVisitor.java at master · jruby/jruby · GitHub
虽然它现在还没有默认开启,但这种优化就是我说的“编译优化的潜力”的一部分。
问了Charles Nutter和Tom Enebo,这个优化应该快能默认打开了(“soon”)。

而CRuby则尚未见到有这方面的动作。