VMに手を加えずRubyを高速化するJITコンパイラ「YARV-MJIT」の話

先日のRubyKaigi 2017のLTではLLVMベースのCRuby向けJITコンパイラLLRBの話をしました。 5分はちょっとJITの話をするには短かかったですね。

LLRBをふまえた、Cのコード生成への軌道修正

さて、上記の資料にある通り、CRubyのJITにおいてはメインの高速化対象が既に存在するCのコードになるため、 開発の早い段階でパフォーマンスにインパクトを持てるとすればLLVM Passの順番を変えるくらいで、 LLVM IRを直接生成しても最適化上のメリットがほとんどないのでその部分はMJIT と同じくCのコードを生成するように変更したい、という話をした*1

で、LLRBはC拡張として作るべくちょっと不思議な努力をいろいろやっており、 それらの設計はやってみた結果(コアに直接変更を加えるのに比べ)デメリットの方が大きいと思ったので、 LLRBの失敗を全て生かしつつ、今回YARV-MJITという奴を新しくスクラッチした。

YARV-MJITとは?

github.com

MJITはJITを実装する前にレジスタベースのRTL命令にVMの命令を全て置き換えた上でそれをベースにコンパイルしているが、 YARV-MJITはVMの命令セットは全てのそのままに、スタックベースのYARV命令をコンパイルする点が異なっている。

逆に言うとそれ以外は全てMJITのパクり、というかそのままコードを持ってきたフォークになっており、 「Copyright (C) 2017 Vladimir Makarov」になってるコードの方が多い。

Optcarrot ベンチマーク

まだ Optcarrot しかまともにベンチマークできてないのでこちらを。

  • Intel 4.0GHz i7-4790K with 16GB memory under x86-64 Ubuntu 8コア で計測
  • 以下の実装を用いた:
    • v2 - Ruby MRI version 2.0.0
    • v2.5 - Ruby MRI 2.5.0-preview1, YARV-MJITの元のベースに近い
    • rtl - Vladimirの 最新の RTL MJIT (21bbbd3) -j なし
    • rtl-mjit - MJIT (-j) with GCC 5.4.0 with -O2
    • rtl-mjit-cl - MJIT (-j:l) using LLVM Clang 3.8.0 with -O2
    • yarv - 僕の YARV-MJIT の -j なしバージョン、v2.5と大体同じはずだが、JITが有効かのフラグのチェックのオーバーヘッドが入る
    • yarv-mjit - YARV-MJIT (-j) with GCC 5.4.0 with -O2
    • yarv-mjit-cl - YARV-MJIT (-j) with LLVM Clang 3.8.0 with -O2

以下のように、MJITほどのパフォーマンスはまだ出せていないが、大体Ruby 2.0の1.59倍くらいは速くなる。

v2 v2.5 rtl rtl-mjit rtl-mjit-cl yarv yarv-mjit yarv-mjit-cl
FPS 35.41 43.36 38.00 75.57 81.25 42.89 56.38 48.27
Speedup 1.0 1.22 1.07 2.13 2.29 1.21 1.59 1.36

なおRailsでのベンチと解説をRails Developers Meetup 2017でやる予定。 そっちも多分内部に触れるけど、よりコアよりの話とかJITの実装の苦労話とかマイクロベンチマークの紹介はRubyConf 2017でやると思う。

何故YARV-MJITを作っているのか?

JITのためにVMの命令をレジスタベースにするメリットが何なのか確かめたい

ko1さんを含めコミッターの人たちは大体RTL命令への変更に概ね皆賛成っぽいんだけど、 同じオブジェクト指向言語でスタックベースのバイトコードJITを持つJavaRubyより十分に速い以上、 JITで高速化をするためにVMの命令をレジスタベースにする必要があるのか、ということにずっと疑問を持ち続けている。

VMJITに馴染みがない人のために説明すると、 VMの命令セットをスタックベースからレジスタベースに変えるというのは大体以下のようなことと同じである:

あなたの会社の社員は全員はEmacsを使っているとする。巷では (anything.el や helm のような) ファジーファインダーでファイルを絞り込んで開くと編集効率が上がると言われているが、現在Emacsにはそのようなプラグインがまだないとする。

そこに突然、EmacsよりもVimの方が編集効率が高いと宣う熱狂的なVimmerが現れあなたは試験的に導入してみたが、 Emacsでできないことが突然発覚するリスクを背負う割にはそれほど編集効率が上がらなかった。 一方、そのVimmerはUnite.vimのようなプラグイン(Vimanything.el的な奴)を開発し、それを使ってみたら開発効率が爆増し、 全社的にEmacsをやめてVimを導入することになった。…というような話なのである。

冷静に考えてエディタは変えずanything.el的な部分だけを作って使った方が安全に決まっているし、 Emacs上でファジーファインダーを開発してもVimの時と同じように開発効率が上がるはず、というのが僕の主張である。

まあ冗談*2はさておき、スタックvsレジスタでのJITにおける本質的な違いが何なのか技術的な興味があって続けている。

JIT基盤の変更とVM命令の全リプレースが同時に入るリスクを軽減したい

上記の考えとは全く別に、僕が開発しているようなLLRBとかに比べて、MJITのクオリティは本当に高い。 なのでRuby 3にMJITが入るのは僕も応援したいのだけど、その一方、 JIT-jをつけるかどうかでオンオフが切り替えられるのに対し、 VMの命令セット全置き換えは切りようがないので、本番で動作しているアプリに投入するにはちょっとリスクが高すぎるように思っている。 特に弊社で動かしているような、一瞬でも止まるとお客様がかなり困るようなRailsアプリとかの場合。

一方現在のCRubyのコードベースでは、コマンドラインオプションで現在のYARV命令とRTL命令をスイッチする(かつRTLの時のみJITが使える)みたいな実装にするのは ちょっと厳しいんじゃないかとも思う。

そうすると、大きな2つの変更である「RTL」と「MJIT」に関して、オプショナルにできるMJIT側の基盤を先に入れてテストし、 後からRTL(とその命令のJITコンパイラ)を入れる方が安全にリリースできていいのでは、というのが僕の意見である。

MJITはよくできていて、JITコンパイラ側はVMの命令が呼び出す関数をインライン化していく作業がメインで、 特殊命令への変換やdeoptimize相当の処理の大部分はRTL命令側に実装されているので、実はJITコンパイラ部分だけ置き換えるのがそんなに大変じゃないと思っており、 マルチスレッドプログラミングが必要だったりポータビリティに難のあるオブジェクトのロードなどのMJITの基盤を先に導入できるメリットは結構あると思っている。

今後の展望

今年はもうなんだかんだ半年くらいJITを書き続けてるので、自分の満足の行くまで好きに続けようと思う。

Optcarrotで少なくとも60fpsは越えられるレベルになり、認識できるレベルのバグが修正され、JIT有効でもほとんどテストが通ったり、 実際のアプリケーションが問題なく動くことを確認できたら、 2.xの間の、3.0のRTL+MJITまでの繋ぎとしていかがですかというような提案ができたらいいなと思っている。

*1:というのは妄想で、僕のトークがスローなので5分では全くその話に到達できませんでした。

*2:この記事を書いている僕は最初Emacsを使っていたけど小指が痛くなったので今はVimに移行している、というオチ