翻译 [译] Ruby 的新特性 JIT

lanzhiheng · February 04, 2019 · 7839 hits

原文链接:https://medium.com/square-corner-blog/rubys-new-jit-91a5c864dd10 Ruby2.6 已经发布了一个多月了,这篇文章显得有点老旧,不过还是有助于理解 JIT 到底是个什么东西,它是如何提升 Ruby 的运行速度的,以及社区为了在 Ruby 里添加 JIT 所作的努力。


CRuby 有 JIT 了。

为了给 Ruby 实现 JIT 功能已经进行过许多尝试,这些参考实现一直以来都没能够被合并,直到今天我们终于有 JIT 了。

                 ,---.    ,---.     .-./`)  .-./`) ,---------.
.     '     ,    |    \  /    |     \ '_ .')\ .-.')\          \
  _________      |  ,  \/  ,  |    (_ (_) _)/ `-' \ `--.  ,---'
 /_\/_ _ \/_\    |  |\_   /|  |      / .  \  `-'`"`    |   \
  \ \    / /     |  _( )_/ |  | ___  | '`|   .---.     :   :
,   \\  //   .   | (_ J _) |  ||   | |   '   |   |     |   |
      \/         |  (_,_)  |  ||   `-'  /    |   |     |   |
   ,      .      |  |      |  | \      /     |   |     |   |
                 '--'      '--'  `-..-'      '---'     '---'

Ruby2.6 将有一个可选的--jit标记用来启用 JIT 功能,这会增加应用启动的时间并且会耗费更多的内存,都是为了在应用启动就绪之后能够获得耀眼的运行速度。

早期的 Ruby JIT 尝试

这里有一些早期为 Ruby 添加 JIT 功能所进行的尝试,像rujit,已经让 Ruby 能够成功提速,不过会耗费过多的内存。另一个尝试,OMR + Ruby使用了已有的 JIT 程序库Eclipse OMR。还有其他案例llrb,它使用基于LLVM的 JIT 库。这些实现能够被预见的最大问题是 JIT 库都是活靶子,会把 Ruby 的幸存者带到一个未知的未来。

一个大的飞跃:RTL MJIT

Vladimir Makarov 为 Ruby 的性能提升作出了不少贡献,他在 Ruby2.4 里重新实现了 Hash 表很大程度地为 hash 访问提速。

在 2017 年,Makarov 主要在跟进一个新的项目,被称为RTL MJIT,重写了 Ruby 中间表现的工作方式并为 Ruby 添加了 JIT。在这个非常有野心的项目里,已经存在的 YARV 指令集完全被崭新的指令集 RTL(寄存器传输语言)所取代。Makarov 同时也创建了一个被称为 MJIT 的 JIT 编译器,将会根据 RTL 指令产生 C 代码,然后通过已有的编译器把 C 代码编译成原生机器代码。

Makarov 的实现的最大问题就是要使用崭新的 RTL 意味着对 Ruby 内部的大规模重写。可能还得耗费一些年的时间来打磨相关的工作,直到某个时间点功能可以稳定并为合并到 Ruby 中做好准备。Ruby3 应该会绑定这个新的 RTL 指令集,不过还不能够确定(应该是几年后的事情了)。

已经被合并到 Ruby 中的 JIT:YARV MJIT

Takashi Kokubun 为 Ruby 的 JIT 以及性能提升方面做了不少贡献。他是llrbJIT 的作者,在 Ruby2.5 的开发过程中多次提升了 Ruby 的 ERB 和 RDoc 的生成速度。

Kokubun 基于 Makarov 在 RTL MJIT 的工作成果,从中抽取了 JIT 功能部分,并保留了 Ruby 已有的 YARV 字节码。他对 MJIT 的功能进行缩减,只保留可以满足需求的最小形式,去除掉了一些较为高级的优化,因此它可以被引入到已有的 Ruby 中,而并不会破坏 Ruby 其他部分的功能。

__  __              _            ___            _____
    |  \/  |          _ | |          |_ _|          |_   _|
    | |\/| |         | || |           | |             | |
    |_|__|_| _____   _\__/   _____   |___|   _____   _|_|_
   _|"""""|_|"""""|_|"""""|_|"""""|_|"""""|_|"""""|_|"""""|倭
   "`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'

Kokubun 的工作已经被合并到 Ruby 中,会随着 Ruby2.6 在 2018 年圣诞节那天发布出去。如果你想要现在就尝试 JIT,你可以使用 Ruby 的构建版。在这个 moment 性能的提升还是相当保守的,在 Ruby2.6 发布之前还会在优化上耗费大量的时间。Kokubun 的策略是先保证安全,然后再逐步优化已有的工作。于是 Ruby 有 JIT 了。(翻译这篇文章的时候 Ruby2.6 已经发布,可以直接尝试稳定版)。

它是怎么工作的

获取 YARV 指令集

JIT

为了运行你的代码,Ruby 必须要经历一些步骤。首先,代码被令牌化,解析,并且编译成 YARV 指令。这部分流程大概会占用 Ruby 程序运行时间的 30%。

Move

我们可以通过使用标准库中的RubyVM::InstructionSequence以及Ripper来观察上面提到的每一个步骤。

require 'ripper'

##
# Ruby Code
code = '3 + 3'

##
# Tokens
Ripper.tokenize code
#=> ["3", " ", "+", " ", "3"]

##
# S-Expression
Ripper.sexp code
#=> [:program, [[:binary, [:@int, "3", [1, 0]], :+, [:@int, "3", [1, 4]]]]]

##
# YARV Instructions
puts RubyVM::InstructionSequence.compile(code).disasm
#>> == disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,5)>==================
#>> 0000 putobject        3                                               (   1)[Li]
#>> 0002 putobject        3
#>> 0004 opt_plus         <callinfo!mid:+, argc:1, ARGS_SIMPLE>, <callcache>
#>> 0007 leave

##
# YARV Bytecode
RubyVM::InstructionSequence.compile(code).to_binary
#=> "YARB\x02\x00\x00\x00\x06\x00\x00\x003\x02\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\xA4\x01\x00\x00\xA8\x01\x00\x00..."

yomikomubootsnap将会向你展示通过把 YARV 指令缓存到磁盘上来提高 Ruby 的运行速度。这样做的话,当 Ruby 脚本第一次运行完之后,指令不需要再次被解析以并编译成 YARV,除非你修改了代码。当然,这不会为 Ruby 的首次运行提升速度,而会为后续的执行提速百分之 30-因为跳过了解析并且编译成 YARV 指令这个步骤。

这个缓存编译好的 YARV 指令的策略实际上并没有 JIT 相关的工作,不过这个策略已经在 Rails5.2 里面使用了(通过 bootsnap)很可能也会在未来的 Ruby 版本中出现。目前的 JIT 只有在 YARV 指令存在的情况下才会工作。

JIT 编译 YARV 指令集

当 YARV 指令存在的时候,RubyVM 在运行时的职责就是把这些指令集转换成能适应你正在使用的操作系统以及 CPU 的原生机器代码。这个过程会占用运行 Ruby 程序 70% 的时间,大块的运行时间。

这也是 JIT 发挥作用的地方。并不是每次遇到 YARV 指令集都会对它进行计算,其中的某些调用能够被转换成原生的机器代码,以后再次遇上的时候便能直接使用原生代码了。

这是一个 ERB 模版,会生成 Ruby 代码,生成 C 代码,通过 JIT 来生成 C 代码。~mjit_compile.inc.erb

使用 MJIT 的时候,某些 Ruby 的 YARV 指令集会转换成 C 代码并且会放置在.c文件当中,它们会被 GCC 或者 Clang 编译成名字为*.so的动态库文件。RubyVM 可以在下次看到相同的 YARV 指令时从动态库中直接使用缓存好的并且经过预编译的原生机器码。

逆优化

然而,Ruby 是一门动态类型的编程语言,即便是核心的类方法都能够在运行时重新定义。这需要一些机制去检测已经被缓存到原生代码中的调用有没有被重新定义。如果这些调用被重新定义,则需要刷新缓存。这些指令就像是在没有 JIT 的环境那样被正常解释。当一些东西有所改变的时回退到计算指令集的过程被称为逆优化

##
# YARV instructions for `3 + 3`:
RubyVM::InstructionSequence.compile('3 + 3').to_a.last
#=> [1,
 :RUBY_EVENT_LINE,
 [:putobject, 3],
 [:putobject, 3],
 [:opt_plus, {:mid=>:+, :flag=>16, :orig_argc=>1}, false],
 [:leave]]
##
# MJIT C code created from the `:opt_plus` instruction above:
VALUE opt_plus(a, b) {
  if (not_redefined(int_plus)) {
    return a + b;
  } else {
    return vm_exec();
  }
}

记住,在上面的例子中,如果调用被重新定义,MJIT 所产生的 C 代码会优化逆行并重新计算指令集。大部分时间里我们都不会重新定义加法运算,这为我们带来好处,因此我们可以利用 JIT 去使用已经编译好的原生代码。每一次 C 代码被执行,它会确认它优化过的操作有没有改变。如果有所改变,就是逆优化,指令集会被 RubyVM 重新计算。

Deoptimization

使用 JIT

你可以通过添加--jit标志来使用 JIT。

$ ruby --jit -e "puts RubyVM::MJIT.enabled?"
true

还有许多试验性的与 JIT 相关的标志位选项:

MJIT options (experimental):
  --jit-warnings  Enable printing MJIT warnings
  --jit-debug     Enable MJIT debugging (very slow)
  --jit-wait      Wait until JIT compilation is finished everytime (for testing)
  --jit-save-temps
                  Save MJIT temporary files in $TMP or /tmp (for testing)
  --jit-verbose=num
                  Print MJIT logs of level num or less to stderr (default: 0)
  --jit-max-cache=num
                  Max number of methods to be JIT-ed in a cache (default: 1000)
  --jit-min-calls=num
                  Number of calls to trigger JIT (for testing, default: 5)

你可以在 IRB 里交互式地使用 JIT

$ ruby --jit -S irb
irb(main):001:0> RubyVM::MJIT.enabled?
=> true

这是早期的代码调试工具,JIT 当然也能够在 Pry 中工作

$ ruby --jit -S pry
pry(main)> RubyVM::MJIT.enabled?
=> true

启动时间

启动时间是使用新的 JIT 功能的时候需要考虑的一件事情。启动 Ruby 时伴随着 JIT 的功能会多耗费大概 6 倍的时间。

time

无论你使用的是GCC还是Clang都会对启动时间有所影响。如今,GCC 被认为是比 Clang 更快的编译器,但是依旧会在带着 JIT 启动的时候多耗费 3 倍左右的时间。

time with gcc

在这种情况下,你可能不会想要在任何存活时间非常短的程序中开启 JIT 功能。不仅是 JIT 需要启动,为了高效,它可能还需要一些时间来热身(一些预编译)。在运行时间较长的程序中使用 JIT 性能表现会十分突出 - 它可以充分热身并有机会使用已经缓存好的原生机器码。

性能

2015 年,Matz 提到了 3x3 宣称 Ruby3.0 将要比 2.0 快 3 倍。官方的 Ruby3x3 的测量工具是optcarrot,一个用 Ruby 写的任天堂仿真器。

现实中任天堂运行的帧率是 60FPS。Kokubun’s 在一台 8 核心 4GHZ 的机器上用optcarrot 做过一个 benchmarks显示出 Ruby2.0 帧率是 35FPS,Ruby2.5 帧率是 46FPS 提升了大概百分之 30。在 JIT 开启的情况下 Ruby2.6 比 Ruby2.0 快了将近百分之 80,帧率达到 63FPS.

optcarrot

这是一个很大的性能提升!为 Ruby 添加 JIT 之后已经让 Ruby2.6 朝着 3X3 的提案迈出一大步。而且刚开始的 JIT 对性能的提升是非常保守的,MJIT 的引入并没有采用许多在 RTL MJIT 身上能够看到的优化方案。即便没有采用这些优化方案,性能的提升还是十分显著的。在一些额外的优化被引入之后,性能可能会更加可观吧。

下面的 beanchmark 展示了 optcarrot 在分别在多个版本的 Ruby 上运行的情况(前 180 视频帧),在 Ruby2.5 和 Ruby2.0 上展现出非常平滑的性能表现。TruffleRuby, JRuby还有Topaz都是目前已经有 JIT 功能的 Ruby 实现。你可以看到这些带有 JIT 功能的实现 (下面绿色,红色还有紫色的线条) 启动比较缓慢并且为了热身会花费掉一些视频帧。

Image by Yusuke Endoh, distributed under MIT license.

在热身之后TruffleRuby在它那被高度优化的GraalVMJIT 的支持下性能遥遥领先。

mage by Yusuke Endoh, distributed under MIT license.

官方的 optcarrot benchmark 目前还没包含 Ruby2.6-dev 开启 JIT 之后的测试结果,不过它还不能与 TruffleRuby 相抗衡。TruffleRuby 虽说性能比起其他实现要领先不少,不过还没有为生产级别做好准备。

修改 optcarrot benchmark 以展示 Ruby2.6-dev 在基于 GCC 开启 JIT 功能的运行情况,我们可以看到为了热身它会消耗掉一些帧。在热身之后,即便有许多优化没有被开启,它也能够与早些版本的实现拉开距离。注意绿色的线条启动缓慢,然而一旦追上来,便会持续保持领先。

Ruby2.6-dev

如果我们放大来看,我们可以看到基于 GCC 并开启了 JIT 的 Ruby2.6-dev 在 80 帧这个点左右将会与 Ruby2.5 拉开距离 - 在 benchmark 中只是占用几秒的时间而已。

frames

如果你的 Ruby 程序存活时间比较短,几秒之后就退出了,你可能不会想要开启新的 JIT 功能。然而如果你的程序将会运行会运行更长的时间,并且你有一定的空闲的内存,那么 JIT 可能会带来可观的性能优势。

未来

在 Square 里我们内部大量使用 Ruby,我们维护了许多Ruby 开源项目包括了连接 Square 用的 Ruby 的 SDK。因此对我们来说在 CRuby 中 JIT 的新特性是激动人心的。而在圣诞节发布之前,还有不少的工作要做,还要引入一些较为容易实现的优化方案。现在请在 Ruby 的 trunk 或者 nightly 版本中尝试 JIT 功能,并报告你遇到的问题。

Vladimir MakarovTakashi Kokubun为 Ruby 引入了 JIT 并把 Ruby 往前推进这件事情上应受很大的赞誉,相信在接下来的几年还会带来更多性能方面的改善。

想了解更多?注册我们每个月的开发者新闻。

No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.