当前位置: 首页 > 工具软件 > Topaz Ruby > 使用案例 >

【译】Ruby的新特性JIT

叶裕
2023-12-01

原文链接: medium.com/square-corn… 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指令集

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

我们可以通过使用标准库中的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重新计算。

使用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倍的时间。

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

在这种情况下,你可能不会想要在任何存活时间非常短的程序中开启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.

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

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

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

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

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

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

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

未来

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

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

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

 类似资料: