Ruby 2.6にJITコンパイラをマージしました

The English version of this article is available here: medium.com

2/4(日)に、去年のRubyKaigiが終わった直後の新幹線で開発を始め10月に公開したJITコンパイラRubyのtrunk (2.6.0-dev) にマージし、昨日TD Tech Talk 2018で以下のような内容の発表をしました。

speakerdeck.com

まだそれほど速くできていないということもあり、私はTwitterでのみ共有して満足していたのですが、海外の方がいくつか記事を書いてくださいました。

とても丁寧に書かれているので、私の記事がわかりにくければそちらもどうぞ。ただ、いくつか補足したい点があったのと、また昨日の発表内でうまいこと説明できなかった点があるので、そのあたりに触れつつ今回マージしたJITコンパイラについての概要を記事にしようと思います。

"MJIT"とは何か

この見出しの内容はある程度知ってる方がいる気がするので、僕のYARV-MJITの話を聞いたことがある人は「RubyJITの現状」まで飛ばしてください。昨日の僕の発表を聞いてくださった方は、発表時に話せなかった「JITコンパイラの自動生成の裏側」まで飛ばしてください。

ここでは、RubyJITにまつわる最新の動向をダイジェストでお伝えします。

RubyVMのRTL命令化プロジェクト

これは2016年7月の話なのですが、Ruby 2.4でHashを高速化 (Feature #12142)したVladmir Makarovさん(以下Vlad)が、VM performance improvement proposal (Feature #12589)というチケットを作りました。

タイトルの通りVMのパフォーマンスを上げる目的の提案なのですが、RubyVMJava等と同じくスタックベースの命令で作られてるのに対し、その命令を全てレジスタベースのもの(以下RTL: Register Transfer Language)に置き換えようという内容になっています。

VladはGCCレジスタアロケータの作者であり、インストラクションスケジューラのメンテナでもあります。そのGCCは内部でレジスタベースの中間表現を使っているため、そこで得たノウハウを適用して高速化しようという目論みだったのでしょう。

全ての命令を書き変えるような大きな変更にも関わらず、make testが通る完成度のRTL命令の実装が公開されました。純粋なレジスタベースへの置き換えだけでなく、実行時にレシーバや引数のクラスに応じてバイトコードを動的に書き変える仕組みを用意して、クラスに応じた特化命令が使われるようにする変更も入っています。

一方この変更だけでは、一部のマイクロベンチマークではパフォーマンスが向上したものの、Optcarrotのようなある程度規模の大きいベンチマークではパフォーマンスが劣化してしまう状態でした。

RTL命令上で動くJITコンパイラMJIT

そのような問題を解決するため、2017年3月末、RTL命令をベースに動作するMJIT (MRI JIT)というJITコンパイラの実装が公開されました。

rtl_mjit_branchのページにRubyJITのための方針がいくつか検討されていますが、採用されたのはCのコードを生成してディスクに書き出し、Cのコンパイラを実行してそれをコンパイルしロードするというものでした。

f:id:k0kubun:20180216205959p:plain

RTL化の際にインライン化に有利な命令列が動的に生成されるようになっており、そのコードをJITすると、先ほどのOptcarrotでもかなり高速(Ruby 2.0の2〜3倍)に動作するようになりました。

一方で、VMの実装を大きく書き換えた副作用として、make testは通るもののmake test-all等のよりシビアなテストはJIT有効/無効に関わらず通らない状態になってしまい、Optcarrotもベンチマークモードでしか起動可能でなかったり、Railsも起動に失敗する状態でした。

YARV-MJITとは

このようにRTL命令化はある程度リスクのある変更だったのですが、私の会社ではクラウドサービスをRailsアプリケーションとして実装して提供しておりとても高い可用性が求められるため、想定外のバグが発覚するリスクの高い変更がRubyに行なわれてしまうと、バージョンアップがとても大変になるだろうという懸念がありました。

RubyKaigiでのVladの発表の後、私はどうにかしてリスクを軽減しながらRubyJITを導入できないか考えました。そこで思いついたのがYARV-MJITと私が呼んでいるアプローチで、MJITのJIT基盤をそのまま使いながら、RTL命令ではなく現在のスタックベースのYARV命令をベースにJITコンパイルを行なうというものでした。

このJITのアプローチはVMの実装を全く変えずに実現できるため、リリース後JITコンパイラにバグが発覚しても、JITコンパイラを無効にしてしまえばRubyが今まで通り動作することがほぼ確実となります。

リスクを抑えるためVMの命令セットに変更を加えないという制約をかけていたのもありMJITほどのパフォーマンスは出せなかったのですが、公開後様々な改善を減てYARV-MJITでもある程度MJITに近い速度が出るようになりました。

いくつかの未熟な最適化を取り除くと、YARV-MJITはある程度の速度を保ちながらもJIT有効な状態でRubyのテストが全て通るようになったため、2017年末にMJITのJIT基盤とYARV-MJITのマージを提案し、ポテンシャルバグやランダムなクラッシュの修正の後、2018年2月に無事マージされました。

RubyJITの現状

Ruby 2.6に向けて今回マージされたのは主に以下の2つのコンポーネントに分けられます。

VladのMJITはもともとJITコンパイラ部分も含めた呼称と考えられるため、そのうちJITの基盤となる部分のことを私はチケットやコミットメッセージ上ではMJIT Infrastructureと区別して呼んでいます。

が、たくさん名前を覚えるのは面倒ですし、ユーザーの皆さんからしたら中の実装に何が採用されたとかはどうでもいいかもしれないので、両方合わせてMJITと呼んでいただくのは別に構わないと思います。

これは昨日TD Tech Talk 2018で話した内容なのですが、現状それぞれどこまで実装が進んでいるのかという点について共有します。

JITコンパイラについて

ベンチマーク

パフォーマンス改善のための変更なので、いくらリリースのリスクを抑える目的があるとはいえ、当然パフォーマンスが向上しなければ導入するべきではないでしょう。なので、私のマシン(Intel 4.0GHz i7-4790K 8 Cores, 16GB memory, x86_64 Linux)でのベンチマーク結果をいくつか載せておきます。

Optcarrot

f:id:k0kubun:20180216210028p:plain

これは最新のOptcarrotベンチマークの結果です。YARV-MJITも一時期68fpsくらい出ていたんですが、そこからいくつか不完全な最適化を取り除いているためこのようなパフォーマンスになっています。

JITのマージのPull Requestでは最初63fpsのベンチマークを貼っていて、Squareさんの記事でもこれを引用されているように見えるのですが、そこから更に最適化を1つ一時的に外しているため、PRの本文に書いてあるようにマージの段階でベンチマーク結果が変わって58fpsになっています。マージ後r62388で59fpsくらいになりましたが、63fpsに届くために必要な最適化*1はまだ外したままです。そのうち直します。

RubyBenchの更新が最近止まっているため正確にどの変更の影響が大きいのか調べるのが難しい状況*2なのですが、最近shyouheiさんがVMで様々な改善を行なっている*3ためか、Ruby 2.6では実はJIT無しでもOptcarrotベンチマークは結構速くなっています。

JITがそれほどパフォーマンスに影響を与えられていないというのは私個人にとっては少し残念な話ですが、Ruby 2.6がJITなしでも速くなっているというのは良いニュースでしょう。

Benchmark in "Playing with ruby's new JIT"

VM実行に比べ現時点のJITの高速化の可能性は本当に上記のように数パーセントしかないのでしょうか?

Playing with ruby's new JIT: MJITの記事にはベンチマークスクリプトがあり、それをtrufflerubyの人が改良したものが以下のようなものになっています。

def calculate(a, b, n = 40_000_000)
  i = 0
  c = 0
  while i < n
    a = a * 16807 % 2147483647
    b = b * 48271 % 2147483647
    c += 1 if (a & 0xffff) == (b & 0xffff)
    i += 1
  end
  c
end

benchmark.ips do |x|
  x.iterations = 3
  x.report("calculate") do |times|
    calculate(65, 8921, 100_000)
  end
end

これを私のマシンでも実行してみました。

$ ruby -v
ruby 2.6.0dev (2018-02-15 trunk 62410) [x86_64-linux]
$ ruby bench.rb
Warming up --------------------------------------
    calculate    13.000  i/100ms
    calculate    13.000  i/100ms
    calculate    13.000  i/100ms
Calculating -------------------------------------
    calculate    1.800k (± 2.7%) i/s - 8.996k in   5.002504s
    calculate    1.785k (± 7.4%) i/s - 8.853k in   5.003616s
    calculate    1.802k (± 4.0%) i/s - 8.996k in   5.006199s
$ ruby --jit bench.rb
Warming up --------------------------------------
    calculate    13.000  i/100ms
    calculate    18.000  i/100ms
    calculate    27.000  i/100ms
Calculating -------------------------------------
    calculate    7.182k (± 9.1%) i/s - 35.397k in   5.000332s
    calculate    7.296k (± 2.9%) i/s - 36.450k in   5.001392s
    calculate    7.295k (± 3.1%) i/s - 36.450k in   5.002572s

1.802k → 7.296k ですから、大体4.0倍くらいですかね。Ruby 3x3達成しましたね。

これは多分JITの宣伝のために作られたような架空のワークロードだと思いますが、正しく現実世界の用途に向けJITをチューニングしていけば、現在の最適化戦略で大きな改善ができる可能性を示唆していると思います。

またこのベンチマークに関しても、手元のtrufflerubyで動かしたところ667.854k (trunkのVM実行の約370倍)になったので、我々もこのような最適化しやすいケースに関しては数百倍のオーダーを目指すべきだと認識しています。

その他のベンチマーク

JITコンパイラをマージした時のコミットのコミットメッセージに、Optcarrot以外にもマイクロベンチマークの結果(JIT offの状態に比べ2倍程度速くなっているのがいくつかあります)と、Railsでのベンチマーク結果を載せています。

Railsベンチマークに関してはNoah Gibbsさんのrails_ruby_benchを使用したいところなのですが、私の手元で動かない状態になっているので、それが直せるまでは現状そのベースとなっているdiscourseリポジトリベンチマークを使用しています。

そのRailsベンチマークだと速くなるどころか若干遅くなってしまうというのが現状であり、原因はまだ調査中なのですが、Sam SaffronさんがUnicornはforkするので子プロセスでJITが有効になっていないのではという指摘をされていて、実際無効にしているので、このあたり直しつつ遅くなっている原因の切り分けもやっていこうとしている状態です。

JITコンパイラの自動生成

JITコンパイラVMと同じ挙動をしなければいけないため、素朴に実装するとJITコンパイラVMの実装のコピペが多くなり、保守性が下がってしまいます。

RubyVMはもともとinsns.defという特殊なテンプレートフォーマットをパースしてVMを生成するERBに渡すことで生成されているのですが、insns.defからJITコンパイラも生成するようにできないかという提案をささださんからいただき、やってみたところ上手くいきました。

以下のようなmjit_compile.inc.erbというファイル(わかりやすいようにいろいろ省略)があり、

switch (insn) {
% (RubyVM::BareInstructions.to_a + RubyVM::OperandsUnifications.to_a).each do |insn|
  case BIN(<%= insn.name %>):
<%= render 'mjit_compile_insn', locals: { insn: insn } -%>
    break;
% end
}

命令に応じてswitch-caseで分岐しながら、_mjit_compile_insn_body.erbというファイルでinsns.defの中身をfprintfするコードを生成しています。

% expand_simple_macros.call(insn.expr.expr).each_line do |line|
%   if line =~ /\A\s+JUMP\((?<dest>[^)]+)\);\s+\z/
        /* JUMPの動的生成コード */
%   else
        fprintf(f, <%= to_cstr.call(line) %>);
%   end
% end

マクロをJIT向けに事前に変換し、またJUMP命令は特にcase-when文に関して簡単な変換が難しいため動的に生成するようになっています。それ以外のコードは基本的にはfprintfの羅列で、このmjit_compile.inc.erbからは以下のようなファイルが生成されます。

switch (insn) {
  case BIN(nop):
    fprintf(f, "{\n");
    {

        fprintf(f, "    reg_cfp->pc = original_body_iseq + %d;\n", pos);
        fprintf(f, "    reg_cfp->sp = (VALUE *)reg_cfp->bp + %d;\n", b->stack_size + 1);
        fprintf(f, "    {\n");
        fprintf(f, "        /* none */\n");
        fprintf(f, "    }\n");
        b->stack_size += attr_sp_inc_nop();
    }
    fprintf(f, "}\n");
    break;
  case BIN(getlocal):
    /* ... */
}

上記はMJITのワーカースレッドとして実行され、実行時に以下のようなコードをファイルに書き出します。

VALUE
_mjit0(rb_execution_context_t *ec, rb_control_frame_t *reg_cfp)
{
    VALUE *stack = reg_cfp->sp;
    static const VALUE *const original_body_iseq = (VALUE *)0x5643d9a852a0;
    if (reg_cfp->pc != original_body_iseq) {
        return Qundef;
    }

label_0: /* nop */
{
    reg_cfp->pc = original_body_iseq + 1;
    reg_cfp->sp = (VALUE *)reg_cfp->bp + 2;
    {
        /* none */
    }
}

/* ... */

} /* end of _mjit0 */

rb_execution_context_tというのはスレッドのようなもので、rb_control_frame_tがコールスタックのうちの1つのフレームを表しているので、あるスレッドのあるフレームで実行できるようなインターフェースになっています。

このファイルをコンパイルしてdlopen, dlsymするとこれの関数ポインタが得られるので、そのポインタが既に存在したらVM実行のかわりにこれを呼び出すことでJIT実行が実現されます。

JITコンパイラの自動生成の裏側

ささださんくらいしか興味がないような気もするのですが、この自動生成のために必要だったことを紹介します。

次のメソッド呼び出し処理のマクロ定義分岐

RubyVMを1つ起動するとsetjmpという重い処理をする上マシンスタックを大きく消費するため、あまり何度も起動したくありません。従ってRubyVMは、Rubyのコードだけを処理している間は別のVMを起動せず使い回すように作られています。

これがRESTORE_REGS();,NEXT_INSN();という2つのマクロで実現されていたのですが、マクロが2つに分かれていると変換もしづらくとても都合が悪いため、これらをまとめて実行するEXEC_EC_CFP()というマクロを勝手に作り、JITの関数内ではそれがvm_exec()というVMを起動する関数を実行するようになっています。書いてて思ったけどここで先にmjit_exec()するようにした方が速いような。

vm_exec.h内で、MJIT_HEADERというマクロが定義されていたらマクロの定義自体が変わるように実装されています。

例外命令のマクロ定義分岐

Rubyの例外は基本的にはlongjmpを呼び出し大域脱出することで実現されているのですが、それがまあとても遅い処理なわけで、Rubyが内部実装の都合で使っているthrow命令は単にVM実行の関数(vm_exec_core)からreturnして、その関数をラップしている関数(vm_exec)で大域脱出先のISeqを線形に探索して実行する、というような実装になっています。

当然このコードはvm_execにラップされていないと動かないのですが、先ほどいったようにこいつはsetjmpするのでそれも呼びたくなく、基本的にmjit_execvm_execを経由せず呼び出しているため、throw命令に使われているTHROW_EXCEPTION()というマクロを、単にreturnするのではなくlongjmpするように変更しています。

これもvm_exec.h内で分岐しています。

例外ハンドラーからのISeq実行の無視

vm_execにラップされてないと動かないのと同じ問題が他の場所にもあり、vm_exec内の例外ハンドラからISeqの実行が始まる場合setjmpの実行がスキップされておりこれも動かないので、その場合そのISeqはJITで実行されないようになっています。

opt_case_dispatchの動的コンパイル

case-whenってありますよね。opt_case_dispatchオペランドにHashが入っていて、キーが単純で高速に分岐できる場合はそこからテーブル引きで分岐先のアドレスが取得されるようになっています。

で、それはVMの実装向けにgotoが走るようになっていてそのままでは動かないので、JITの実装向けにJITの関数内でのラベルにgoto先を変換しています。

これにはそのHashの中身を見る必要があり、これを見るためにHashの操作を行なう関数を使っていたらランダムにSEGVしていた(GVLのコントロールから外れているMJITのスレッドでRubyVMを利用すると、当然同時にメインのスレッドでも使っているので壊れてしまう)ので、Hashからstを取り出し、stを操作する関数を使うようにしています。

プログラムカウンターの移動

これはRubyのメソッドJITコンパイラの実装初心者(誰)がハマりがちな罠なのですが、一見不要そうに見えるプログラムカウンターの移動をサボると、突然例外で大域脱出した時に実行するべきISeqのlookupが意図通りではなくなってしまいます。なので、全ての命令の実行時に毎回プログラムカウンターを動かす必要があります。

スタックポインターの移動

もともとYARV-MJITはVMのスタック操作相当のものをJITされた関数のローカル変数上で行なうようになっており、それによってそのJITされた関数内でのスタック操作が他の関数から影響を受けないことを保証してコンパイラに最適化させるという意図を持っていました。値を適切なローカル変数に代入してVMのスタックの挙動を再現するため、JITコンパイル時にスタックのサイズを計算するコードが入っており、スタックポインタ相当の計算をコンパイル時に終わらせることができるようになっています。

が、これだと例外で大域脱出した瞬間そのローカル変数の値が失われてしまうという問題がありました。そのため、この速さを維持するためにはlongjmp前にlongjmpで失われる全てのフレームのローカル変数をそのフレームの外側から退避する必要がありました。

それの実装がまだできてないので現在はローカル変数のかわりにVMのスタックを使用しており、スタックポインタも毎回動かさなければならない状態になっており、これが最初のPull Requestの提案から遅くなってしまったポイントです。

この退避の実装を実装するためにローカル変数のアドレスを必ず取る必要が発生するので、もしかするとそのせいでコンパイラが最適化できなくなり、例外が絶対に発生しなさそうなISeqではそれをスキップした方がいいかなあ、とか考えています。

JIT基盤について

プラットフォームの対応状況

MJITのワーカースレッドがpthread直書きだったのを、YARV-MJITの開発中にRubyのスレッドの抽象化レイヤーを使って移植するのはマージ前に終わっていました。

マージ後、MJITのランタイムではなく、JITコンパイル時に使用するヘッダーのビルドが以下のようなプラットフォームで動かなくなるのを報告いただいたり、RubyCIで確認したりしました。

私の手元にはLinux, macOS, MinGW, 新しめのVisual Studioの環境があり、そこで動くことは確認していたのですが、Rubyが要求するポータビリティにはそれでは足りなかったようです。NetBSDSolarisVMをインストールしたり、hsbtさんにICCの環境を用意していただいたりしてデバッグし、どうにかビルドが通る状態に持ってきました。

その後、同期的にコンパイルを行なう--jit-waitというオプションを用いてRubyユニットテストJITのテストを追加し、gcc+Linuxという環境では基本的に動作しているのですが、clangとMinGWの環境でいくつかRubyCIの失敗が残っていて、これらはこれから修正していこうと思っている状態です。

また、Windows環境に入っているCのヘッダは、コンパイル時間を短縮するための変換をしようとするとエラーになる状態になっており、一旦それをやらないようにしているので、MinGWではJITは一応動いていますがコンパイル時間がかなり長いという問題があります。これはヘッダの変換のバグを直すしかないでしょう。

セキュリティ

JIT基盤に関してはnobuさんやusaさんがいろいろ修正をしてくださっていて、その中でnobuさんがセキュリティの脆弱性についても修正してくださっています。

MJITのワーカースレッドがCのファイルを書き出す時に/tmp/_ruby_mjit_p123u4みたいなファイルをfopenしていたのですが、まずファイルのパーミッションに対するコントロールがなかった(これはnobuさんからの指摘で認識していたのでそのうち直すつもりでマージしていた)のと、既存のファイルに書き始めると任意のパーミッションになってしまうため、ファイルを作成する時もそのパスに既にファイルがないことが保証されるコードになりました。

いまのところそれ以外の脆弱性は確認されていませんが、リリース前に精査する必要がありそうです。現時点で本番環境で試していただくのは、こちらとしては助かるのですが、正式リリースまではある程度自己責任でやっていただく形になってしまいます。

その他の補足

"Startup Time"について

Ruby's New JITには、"Startup Time"というセクションで以下のように説明されています。

Startup time is one thing to take into consideration when considering using the new JIT. Starting up Ruby with the JIT takes about six times (6x) longer.

重要な気付きだと思います。この理由は、--jitがついているとRubyの起動時にJIT用のprecompiled headerのコンパイルを開始し、またRubyの終了時にそのJITワーカースレッドが現在行なっているJITコンパイルが終わるのを待つため、最低でもCのファイル1回分のコンパイル時間がかかってしまいます。コンパイルが遅いコンパイラを使うとこの時間は伸びるわけです。

解決策は以下の2つあります。

  • プリコンパイルドヘッダをビルド時に生成する
  • JIT用スレッドをいきなりキャンセルする

プリコンパイルドヘッダは実際にはRubyのビルド時にはプリコンパイルされていません。これはコンパイラのバージョンが途中でアップグレードされプリコンパイルドヘッダのフォーマットが変わってしまうのではないかということを懸念して毎回コンパイルしていることによります。もしサポートしているコンパイラ全てでそのようなことがほとんどないことがわかればそれで良いでしょうが、現在は保守的な方向に倒しているためこうなっています。

JIT用のスレッドを任意の瞬間にキャンセルして、途中で生成されていた全てのファイルを上手いこと特定して削除できる方法があればより速く終了できるのですが、これはあまり安全な方法ではないでしょう。

--jit-ccはなくしました

記事では--jit-cc=gccが使われていたりするのですが、JIT時に使うヘッダの生成に使用したコンパイラ以外のコンパイラJITに使用すると割と多くのケースで壊れてしまい、それが壊れるのはRubyの責任ではないのでサポートしないことにしています。

--jit-ccは間に合せで残していたオプションなのですが、nobuさんがいろいろ改善してくださって、Rubyのビルドに使われたコンパイラ(JIT用のヘッダの生成にも使われる)をマクロで判定してコンパイラを特定するように変わりました。

従って、普段gccを使っているがclangでのパフォーマンスも確認してみたい、という人はclangでRubyをビルドし直すようにしてください。

謝辞

Ruby 2.6のリリースまで私のJITコンパイラが残っているかはまだわかりませんが(!?)、RubyJITが入るまでに様々な方に助けていただいたので、ここで以下の方にお礼を申し上げます。

  • MJITの基盤を発明したVladimir Makarovさん
  • 様々な場所でお世話になっているまつもとさん
  • YARV-MJITについて様々な相談に乗っていただいたささださん、mameさん
  • マージ前に多くのバグを報告・修正していただいたwanabeさん
  • 最初のMinGWサポートを書いたLars Kanisさん
  • ビルド環境でいつもお世話になっているhsbtさん
  • マージ後基盤の方を直してくださっているnobuさん、usaさん、znzさん、knuさん
  • コードを通じてJITコンパイラの生成回りで助けていただいたshyouheiさん

Ruby 2.6.0 preview1 そろそろ出ます

JITが入ったのでpreview1早めに出しませんかみたいな提案があり、成瀬さんがpreview1の準備を始めています。 その時点ではJITはそれほど速くなってないと思いますが、新しいものが好きな方はそこで少し遊んでみていただけると助かります。

*1:冒頭のスライドでLazy stack pointer motionと呼んでる奴

*2:私がRubyアソシエーションの開発助成金で行なっているベンチマークプロジェクトでも3月にそのような継続的ベンチマーク環境を用意しようとしているところなので、来月にはわかりそうです

*3:https://github.com/ruby/ruby/pull/1779 など

2017年にやったこと

要約

今年はクックパッドからTreasure Dataに転職し、Rubyコミッターになり、結婚しました。

発表

今年は7回発表したのですが、9ヵ月くらいJITを触っていたので3回はJITの話になってしまいました。

Cookpad TechConf 2017: 快適な開発環境, CI, デプロイ等の話

前職でやっていた仕事の中でまだ発表していなかった工夫について話しました。2018年は、現職で前職とは少し要件の違うデプロイ環境を整備していくことになっているので、やっていきたいと思います。

TreasureData Tech Talk 201706: TD APIの話とERBの高速化の話

RubyコミッターとしてメンテするようになったERBをRuby 2.5で2倍速くした話とかをしました。

TokyuRuby会議11: Haml 5の高速化の話

Haml 5で内部の設計を結構大きく変え、3倍速くした話をしました。これに起因したバグはいまのところなく、とても上手くいったように思います。

ぎんざRuby会議01: Railsのパフォーマンス改善全般

計測の重要性について語りつつ、今までの発表とは異なる点として、最近バイトコードを読みながら最適化をするようになったのでそのあたりの知見を共有しました。

RubyKaigi 2017: LLVMベースのJITコンパイラ LLRB

今年Ruby向けに2つのJITコンパイラを実装したのですが、その1つ目の話です。LLVMJITを実装するのは割と素直な方針だと思っていたのですが、やってみると一番必要なのは既にCで書かれたコードをいかにインライン化するかというのが肝になるので、LLVMを生かしきれるステージはまだ先かなあという感じでした。

RubyConf 2017, Rails Developers Meetup 2017: JITコンパイラYARV-MJIT

2つ目のJITコンパイラ。upstreamへのマージ計画 https://bugs.ruby-lang.org/issues/14235 もある本命プロジェクトです。がんばっています。

JITコンパイラはいかにRailsを速くするか / Rails Developers Meetup 2017 // Speaker Deck

ホッテントリ

はてなブログを9記事、qiitaに20記事書きました。「要約」とか「発表」と重複あるけどブクマをいただいたのは以下の記事。

タイトル
1. Linux デスクトップ環境 2017 - k0kubun's blog
2. GraphQLは何に向いているか - k0kubun's blog
3. Railsアプリケーションのパフォーマンス改善手法 / #ginzarb // Speaker Deck
4. Hamlを3倍速くした - k0kubun's blog
5. Treasure Data に入社しました - k0kubun's blog
6. CRuby向けのLLVMベースのJITコンパイラを書いている話 - k0kubun's blog
7. VMに手を加えずRubyを高速化するJITコンパイラ「YARV-MJIT」の話 - k0kubun's blog
8. 快適なサービス開発を支える技術/Cookpad TechConf 2017 // Speaker Deck
9. Ruby コミッターになりました - k0kubun's blog
10. LLVM-Based JIT Compiler for CRuby // Speaker Deck
11. ISUCON7予選2日目「Railsへの執着はもはや煩悩」で予選通過した - k0kubun's blog
12. ISUCON7本戦「Railsへの執着はもはや煩悩」で4位だった - k0kubun's blog

OSS活動

今年リリースしたOSS

Star リポジトリ
★242 k0kubun/llrb
★28 k0kubun/yarv-mjit
★16 k0kubun/benchmark_driver

今年はほとんどJITを作っていたのであまり新しいものを作れませんでしたね。

benchmark_driverというのはRuby Grant 2017として採択されたプロジェクトで、ツールの開発は一旦キリがつき、来年ベンチマークセットやCIの整備をやっていくところです。

あと仕事でJavaを使ってbigdam-queueというミドルウェアを作ったので、それは来年公開しようと思っています。そこでJavaに少し慣れたので、既存のRailsアプリのワーカーをJavaで書き直したりもしていました。

Contribution

今年はHamlRubyのコミッターになったのでそのあたりに割とコミットしていました。

なんかHaml 5x3とか言ってRuby 3x3を煽っていたけど、いつのまにか自分がRuby 3x3をやっていく感じになっていました。

2018年は

ひとまず1月を目安に以下のPRがマージできるよう、残っているバグを全部取り除いていくのをがんばりたいと思います。その先で、 今ある最適化のアイデアをどんどん入れてRailsとかも速くしていけるといいですね。

github.com

ISUCON7本戦「Railsへの執着はもはや煩悩」で4位だった

ISUCON7本戦に「railsへの執着はもはや煩悩の域であり、開発者一同は瞑想したほうがいいと思います。」チーム (@cnosuke, @rkmathi, @k0kubun) で参加し、4位でした。

本戦の概要

予選より参加者は少ないと思うので軽く解説しておくと、クッキークリッカーを模したトラフィックのほとんどがWebSocketのアプリで、1万桁とかのスコアを計算する都合ほとんどのチームのボトルネックが最後までBigintの演算になるような問題でした。

僕らは15:00くらいに1位になり、その後はスコアをそれほど改善できず終わってしまいました。

f:id:k0kubun:20171125222955p:plain

方針

最近Ruby向けのJITコンパイラを開発している ので、それを使ってバーンとやろうと思ってましたが、これは開始前にgfxさんにバラされたのでやめました。

というのは嘘ですが *1 、WebSocketと聞いた時点で並列度が必要になるんじゃないかなあと思ってた *2 のと、アプリのCPU演算がボトルネックになってた *3 ので普通に使って速い言語としてGoを使うのを最初から視野にいれていました。

一方で、チーム名からもわかるように我々はRubyに最もなじみがあり計測のノウハウがあるのと、また普通にRubyのコードはGoのコードに比べ文字数が少なく見通しが良かったので、問題を把握するまでの期間Rubyで参戦し、後にGoに切り替えました。

最終形の構成概要

僕は開始2時間くらい寝起きでぽよってたので @cnosuke や @rkmathi が最初に考えた内容ですが、大体以下の感じになりました。 問題の性質上、WebSocket以外を受けるノード以外の構成はどこも同じだったと思います。

  • appサーバ1 (ベンチマーク時この1台のみ指定)
    • WebSocket 以外を返す puma (Sinatra, Ruby)
      • 4つのサーバーに負荷をうまいこと分散していたようですが、僕はこの辺担当してないので他のメンバーの記事にご期待ください
      • WebSocketを担当するウェイトは他の3つが多くなるようにしていたようですが、どのサーバーもCPUは全部使い切れていたようです
    • WebSocket を返すGo
    • nginx (他はGoが直接受ける)
    • puma, Go用の同ホスト向けMySQL
  • appサーバ2
    • WebSocketを返すGo
    • 同ホストのGo向けMySQL
  • appサーバ3
    • WebSocketを返すGo
    • 同ホストのGo向けMySQL
  • appサーバ4
    • WebSocketを返すGo
    • 同ホストのGo向けMySQL

やったこと

github.com

僕は業務で一番Rubyを触っている時間が長そうで、またGoもちょっと前はよく書いてたので、主にアプリのコードをいじる担当でした。逆にインフラが絡む部分は他の人に任せています。

やった人 やったこと
7112 rkmathi Python初期スコア計測
5980 rkmathi Rubyに変更、初期スコア
4621 rkmathi Goも一応確認、初期スコア (その後Rubyに戻す)
- cnosuke サーバーやリポジトリのセットアップ
- k0kubun NewRelic をいれたが、99% WebSocketであるということだけがわかり、相性が微妙だったので今回は使用をやめた
- k0kubun 予選でも使用したStackProfミドルウェアを入れた。 calcStatusボトルネックであることがわかる。 今回もRubyを使っている間結構役に立ったが、StackProf周りは別のところで解説しようと思っているので今回は説明を省略
- k0kubun m_item テーブルを定数としてメモリに持つようにし、m_item のクエリを全てなくす。あまりスコアは変わらなかった
- k0kubun get_power, get_price のBigintの計算がボトルネックになっていたので、これをcountが0〜50の場合の結果をアプリ起動時に作るようにした。 そこそこスコアが上がった気がする
8968 cnosuke 採番して 2,3,4 にWebSocketのリクエストを分散
- rkmathi 2,3,4に個別のMySQL用意
- rkmathi 再起動耐性のための設定
- k0kubun 50個のキャッシュを500個にしてみたら全然起動しなくなった。この時点でも150までのカウントを使っていたが、150とかにしてもメモリの使用量がモリモリ増えて速攻で2GB使い切ってしまったので、Pumaのプロセスを減らすかとか考えていた(preload_app も試すべきだった)が、GILあるしなあとかまあそういうことを考えるのが面倒なので僕はここでRubyをやめることにした。
- rkmathi innodb_buffer_pool_sizeなど、MySQLの設定とか
- cnosuke WebSocket以外を返しているノードへのWebSocketへの負荷減らし
- rkmathi サーバーで動いているのをRubyからGoに切り替え
- k0kubun Rubyにやった変更(m_item, power/priceのキャッシュ)を全てGoに移植
- k0kubun power/price のキャッシュを 50 → 80に調整。 このパラメータをいじるだけでスコアが上がることを発見するが、起動時間が指数関数的に述びていくのでこのへんで断念したが、このあたりからcnosukeが起動時間を縮めるためこれのキャッシュをシリアライズするのを着手していた
- rkmathi 僕らが誰もGoのプロファイリング方法を知らないのでググり始める。rkmathiが速攻でpprofの使い方を理解し結果を共有してくれたのでとても助かった。始めて使ったけどtopとlistだけでかなり多くのことがわかる。あとなんかrkmathiがコールグラフsvgにしていたが、これも便利だった
- cnosuke WebSocketだけをさばくノードでnginxをなくしてGoで直接受けるように。 今回はあまり効果はなさそうだった
- k0kubun やたら 値が1000の big.Int インスタンスを生成してるっぽかったので毎回同じものを使うように (効果なし)
24227 k0kubun calcStatus内で 1000 * 13 のオーダーでやっているかけ算 + 比較を、13のループの外側で割り算 + ループ内比較 にすることでそこのかけ算のコストを 1/13 に。割とスコアが上がっていた
- k0kubun calcStatus内で 13回やっているかけ算を1つの割り算にできるものをもう1つ発見し、適用。 ここは1000回ループの外なのでインパクトは小さそう
- k0kubun 「1000 * 13回 のかけ算」 → 「1000回 の割り算」 に減らしたオーダーを、 「13回のかけ算」に減らした。これも効果があった記憶
- k0kubun この時点でpprofを見る限りでは big.Int の String()が90% くらい使っていたし、GoがCPUをほぼ使い切っていたので、僕は後半ずっとこれをどうにかできないかもがいていた。なんかfloat64に変換してLog10で桁数計算したり(これは変換した時点で精度が破滅)、なんかpprofのsvgの読み方をちょっと間違えてString()の後のItoaが重いと勘違いして、Exponentialインスタンスを作る時に整数への変換をスキップしたりしたが、無意味であった。これ以降僕はスコアを上げられていない
- rkmathi puma のワーカー数いじり
- rkmathi longtext -> varchar のalter
- cnosuke power/priceのキャッシュをYAMLシリアライズしてロードする奴を入れるが、バグのためrevert
27328 rkmathi 再起動テストとかプロファイリングとか。我々はプロファイラを入れるとスコアが上がるジンクスを持っており、pprofを有効にした時最高スコアを更新した
- cnosuke YAMLの奴をデバッグしていて、終了間際に原因がわかるが、ベンチマークを何度も回す余裕がなさそうなので、ここで変更を加えず何度かベンチを回して終了

やり残したこと

気付いていたのはaddingとかが過去になった奴をまとめておけそうなくらいですが、僕らの計測の限りではそこはボトルネックではなかったので着手しませんでした。ずっとボトルネックであった big.Int.String() や、 price/power の計算結果キャッシュ数を増やすのをがんばっていたが両方失敗してこのスコアに留まった形です。

1位のMSAは、僕らはボトルネックだと思っていなかったあたりのオンメモリ化や過去の結果のマージを全て終わらせて高いスコアを出したようなので、計測は難しいですねという感じです。

我々は気付かず懇親会で知ったのは、(具体的な内容はそちらのチームのブログに任せますが) ソン・モテメン・マサヨシ チームのgoroutineを使ったものやGCのチューニングで、同じGoのアプリをいじってるにも関わらず僕らとは全く違う方向性で速くしていたようなので、その辺はGoへの慣れの差が出たような気がします。

感想

最後のISUCON本戦参加は学生枠で惨敗という感じだったのが、3年経って同じメンバーで社会人枠で健闘でき、成長した実感が得られたのがとても良かったです。

時の運次第では優勝に手が届きそうな感覚が得られたので、来年は優勝したいと思います。参加された方と運営の皆さま、お疲れ様でした & ありがとうございました!

*1:単純にISUCONで使うならもうちょっと直したいところがあったが、本戦までに直していなかった

*2:これはそうでもない

*3:実際にはRubyのBignumはGoのbig.Intに比べ別に遅くないそうなのでこれも別に該当しませんでしたが、後述するようにメモリで断念しました

ISUCON7予選2日目「Railsへの執着はもはや煩悩」で予選通過した

ISUCON7予選に「railsへの執着はもはや煩悩の域であり、開発者一同は瞑想したほうがいいと思います。」チーム (@cnosuke, @rkmathi, @k0kubun) で参加し、217,457点で予選通過だったようです。 正確な値は覚えてませんが、Best Scoreは25万くらいでした。

f:id:k0kubun:20171022224227p:plain

最終形の構成概要

  • appサーバ1
    • puma 16スレッド: 画像のアップロード/表示、雑多なリクエスト対応
    • puma 2スレッド: GET /fetch だけ返す
  • appサーバ2
    • puma 16スレッド: 雑多なリクエスト対応 (画像はnginxがサーバ1に流す)
    • puma 2スレッド: GET /fetch だけ返す
  • DBサーバ

サーバ1, サーバ2をベンチマーク対象にしていました。この構成なのは GET /fetch がスコアにカウントされないため、それ以外にほとんどの時間を使えるようにするためでした。

やったこ

最終コード・他のメンバーのブログはこちら:

github.com

ISUCON7「Railsへの執着はもはや煩悩(ry」で予選通過した - 明日から本気だす

学生のころから何度も同じメンバーで出ているので、いつも通りチーム内で割と綺麗に並列に仕事ができました。

やったことと点数の推移の記録とかはやってないので、やったことだけを適当に列挙していきます*1。グラフからわかる通り多分序盤で意味のある改善が終了しており、終盤はあまりうまくいっていませんでした。

効果があった気がする奴を太字にしておく。

やった人 やったこ
cnosuke 公開鍵の準備とか
rkmathi リポジトリへの主要なコードの追加とか
k0kubun pythonruby変更、systemdの設定のリポジトリへの追加
k0kubun NewRelicのアカウント管理、インストール。この時点では GET /fetch が支配的だったのを確認
k0kubun sleepを消したり消さなかったりした後消す
k0kubun 適当にSELECTするカラムを消したり、messageテーブルにインデックスを貼ったり
k0kubun rack-lineprofを眺めるがほとんど参考にならなかった
cnosuke DBに入ってる画像をファイルにしてnginxから配信できるように変更 (ここで割と上位に来た)、多分Cache-Controlとかもこの辺でついてる
k0kubun GET /fetch を捌くpumaのプロセス (1スレッド)をまだ何もいない2つ目のサーバーに用意し、それ以外を元々いたpuma (16スレッド) に捌かせるようにした (これも結構上がり、後々もインパクトがあった)
rkmathi fetch用pumaのポートを別のサーバーからアクセスできるようにした
k0kubun (本来はruby実装のclose漏れによる)fdの枯渇をOSのfdを増やしたり nginxのworker_rlimit_nofile をいじったりしてどうにかしようとあがくがうまくいかない
cnosuke 2つ目のサーバーもfetchではないメインのpumaがレスポンスを返せるようにnginxを設定、画像を返すのを1つ目のサーバーに絞ったりとか
k0kubun close漏れに気付いて直す、多分workloadが上がってスコアが伸びる
rkmathi get_channel_list_info のループの余計な処理削り
k0kubun なんかsleepを0.2とかいれてみるがスコアが下がるのでなくす
rkmathi NewRelicで計測
cnosuke MySQLの設定がうまく反映されてない奴とかの対応
k0kubun GET /message のN+1つぶし、N=0で壊れるので直したり
k0kubun 自分のプロフィールの時に不要なクエリを減らす、そこのSELECT *のカラムも絞る。NewRelicのブレークダウンがなんかいつもと違って全く詳細になってなくてあまりインパクトのない変更を繰り返している
cnosuke MySQLがいるサーバーのメモリが使われるように設定を修正したり
rkmathi GET /history/:channel_id のN+1つぶし
rkmathi NewRelic見たり
k0kubun さっき自分がN+1で踏んだrkmathiのコードのバグとり
k0kubun GET /fetchのつぶしやすそうなN+1を1つつぶす
k0kubun fetchするpumaのスレッド数を適当にいじり2つにおちつく
k0kubun NewRelicのthread profilerを使うが、今回はびっくりするほど出力が見辛くはっきりいってほとんど役に立たなかった
rkmathi ランダムにする必要のないsaltを固定化
k0kubun Mysql2::Clientをリクエストごとに作るのをやめ、スレッドローカルに使いまわすようにする
cnosuke この辺でk0kubunがperf topを眺めてて、appサーバーがrubyよりlibzが支配的だったので、nginxのgzip_comp_levelをいじったりしている
k0kubun perfを見る限りではrubyボトルネックではなさそうだったが、ERB回り遅いのではと言われたので僕がERBを速くしたruby 2.5に変更(特にスコアに変化はない)
rkmathi GET /register が静的なのでnginxだけで返すように変更
cnosuke DBサーバーにもnginxをたて、そこからプロキシだけすることでglobalな帯域を有効に使えないか試したが、うまくいかなかった
k0kubun TD社内で書いた秘伝のstackprofミドルウェアを取り出し、何が遅いのか計測。rack-lineprofより今回はこっちの方が猛烈に見易かった。fetchでcounter cacheが効きそうなことに気付くが時間の都合でやらない
cnosuke なんかlibzまわりの関係でgzをいろいろやってる
cnosuke 画像を受けてるサーバーは1つだけだが、2つ目の方も最初からある画像は返せないか試していたが、うまくいかない
rkmathi 再起動テスト
rkmathi ログの出力を消したり
k0kubun rkmathiの変更で/registerがoctet-streamになってて動作確認がしにくかったので text/htmlにしてる
cnosuke bigintをintにしてた(のを↑のどこかでやってた)都合で壊れてるのがあって、initializeでauto incrementをリセットする変更をいれてる
k0kubun 余計なcreated_at, updated_atを作らないようにした

あとどこかでtextになってるカラムをvarcharにしたりとかしてました。

心残り

appサーバ1, 2の帯域両方をどうにかして画像のリクエスト処理に使えるようにしたかった(パスのどこかが奇数か偶数かでどちらかに固定で送るとか)ですが、いろいろ考えたけど僕はあまりいい対応が思いつかなかったです。最後 POST /message がボトルネックだったのも、MyISAMにしたりMEMORYにしたりしましたが効果はなかったですね。Redisにするみたいな大きい工事はできませんでした。

GET /fetchのレスポンスは更新があるまで止めておくのが良い(ベンチマーカーがそういう風にスコアをつけてる)、というのを感想部屋で聞いたけど、これも全く気付きませんでした。

あと、奥の手としてJITを考えてましたが全くRubyボトルネックが移せなかったのも残念ですね。

気持ち

上位のチームには相当離されていたので、どこに自分たちが見過していたブレークスルーがあったのか知るのが楽しみです。 本戦に出られるのは学生枠で出してもらっていた時ぶりですが、いい結果を残せるようがんばります。

*1:僕以外の人のタスクは僕が把握してるレベルしか書けないので多分かなり抜けててます

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に移行している、というオチ

GraphQLは何に向いているか

今年GitHubGraphQL APIを正式公開したあたりから、GraphQLが去年とかに比べちょっと流行り始めたように感じる。idobataがGraphQL APIを公開したり、Kibelaも公開APIをGraphQLで作ることを宣言している。

利用者側からすると使えるインターフェースの中から必要なものを調べて使うだけなのであまり考えることはないのだが、自分がAPIを提供する立場になると話は変わってくる。REST APIとGraphQL APIはどちらかがもう一方のスーパーセットという風にはなっておらず、どちらかを選択すると何かを捨てることになるので、要件に応じてどちらを選ぶのが総合的に幸せなのか考える必要がある。

以前趣味でGitHub連携のあるサービスを作っており、それを最近GraphQL API v4を使うように移行し、そこでついでにそのサービスのGraphQL APIを書いてみたりした結果GraphQLができること・できないことが少し見えてきたので、僕の現在の「GraphQLはREST APIに比べどういう用途に向いているか」についての考えをまとめておく。

REST APIと比較したGraphQL

この比較では、REST APIJSON Schemaと同時に使われうることも想定して書く。理由は、GraphQLが解決している問題の一部をREST APIで実現するためにJSON Schemaが使われることがしばしばあり、逆にGraphQLでは元々それに近い機能がありまず使わないと思うので、現実世界の問題を解く上ではそれらをセットにして比較した方がいいと考えたため。

なお、対比する上で本質的な部分となる「仕様上の問題」と、時間が解決しうる(が現実世界では当然考慮が必要な)「現在のエコシステムの問題」は分けて記述する。エコシステムに関しては筆者の都合でサーバー側はgraphql-rubyを念頭に置いている。

GraphQLにしても解決できない問題

仕様上の問題

  • GraphQLはありとあらゆるリソースをリクエスト一発で取得できる夢の技術ではない
    • 正直触る前は大体そういうイメージだった
    • 例えばページネーションなしに1種類のリソースを6000個取得しようとするとレスポンスに1分かかりリバースプロキシ(かunicorn, rack-timeout等)でタイムアウトになるREST APIが世の中には実在したのだけど、それをそのままGraphQLのクエリで再現したとして確実に同じ時間がかかる。つまりページネーションは確実に必要で、クライアント側でそのリソースに関してループを回す必要があり、そのリソースに関して何度かリクエストが必要になる。
    • 単に1つのリソースをページネーションしないといけない場合だけでなく、N個あるリソースにそれぞれM個リソースがネストしていてぞれそれにorderが必要な場合、これは確実にN+1回クエリが必要なわけだけど、そうやって裏側で非効率なクエリが走るようなクエリを投げてしまうと一回のリクエストに時間がかかりすぎてタイムアウトするリスクがあるので、ある程度は分けないといけない。実際僕もGitHubのGrpahQL APIを叩いているとリトライをしても結構タイムアウトを見た。
    • 後述するようにリクエスト数は減ることは多いが、必ず1回にできる銀の弾丸ではないという主張

現在のエコシステムの問題

  • 現時点ではAPIクライアントを自動生成できるライブラリは限られており、アプリ側にいちいち長いクエリを書く必要がある

GraphQLにすると困る問題

仕様上の問題

  • クエリをパースしないとキャッシュの可否を判定できないため、HTTPキャッシュが難しい
    • REST APIであれば、同じとみなせるGETリクエストをVarnish等でキャッシュすることが容易かつ効率的にできるが、GraphQLだとリクエストボディのJSONをパースし、その中に入っているqueryをパースし、そこにmutationがあるかどうかをチェックする必要がある。
    • ワークアラウンドとしてquery fieldが生えてるエンドポイントとmutation fieldが生えてるものを分ける等が考えられるが、エンドポイントを分ける(REST APIに近づける)ほど当然キャッシュのコントロールがしやすくなるわけで、HTTPキャッシュがないと困る用途には向かないと思う。
    • 追記: id:yamitzky さんのコメントで知りましたが、GET /graphql?query=...といった形でのリクエストも仕様上想定しているため、これは誤解のようです。graphql-rubyのgeneratorだと生えないので勘違いしていました。
  • HTTPのメソッドやステータスコードによる挙動の予測ができなくなる
    • queryとmutationしかないということは、HTTPメソッドのGETかPOSTしかない状態に等しく、mutationの中でそれがリソースの追加・更新・削除のうちどれなのかを表現する方法は別に仕様レベルでは標準化されておらず、実装した本人以外から見たら挙動が予測しにくくなる。
    • クエリの結果がエラーになっても大体200 OKが返ってくる。(OKとは)
      • 他にもGitHubの場合502 Gad Gateway: This may be the result of a timeout, or it could be a GitHub bugといったエラーが結構頻繁に返ってくるんだけど、timeoutの場合はリトライしたいしGitHub bugならリトライすべきではないのでこれはHTTPステータスコードで区別して表現してほしい。本当はどちらも(バグを含む何かが原因の)タイムアウトなのかなあ(そのうちサポートに確認する)。
      • errorオブジェクトのルールをちゃんと決めて実装すれば解決できるけど、このあたりに標準的な仕様やガイドラインが存在しない結果そういうレスポンスを生んでしまうのは問題だと思う。
  • 必要なfieldを必ず明示しなければいけないので、自動生成しない限りはAPIクライアントを書くのに必要なコードの文字数・行数は増えそう
    • 後述するようにIDEのGraphiQLのアシストがあるのでそこまで大変ではない

現在のエコシステムの問題

  • GraphQL Proを使わないとモニタリングが難しい
    • そこまで高くはない($900/year)ので仕事でやってるなら普通に金払えばいいんだけど、NewRelicとかで詳しくモニタリングしたかったらGraphQL::Proを使う必要がある
    • graphql-rubyとかにはinstrumentationの仕組みがあるので、まあ困ったら自分で実装することは可能
  • Railsで使ったらMVCのレールのうちVCから割と外れる
    • まずcontrollerとviewに入っていたはずのロジックが全てapp/graphql以下にくることになる。VとCが一緒になるだけだったら最悪いい(?)気もするけどqueryやmutationといったrootのフィールドにはいろんなリソースのロジックが一箇所に集まってくる上にroutes.rb相当の情報も来てしまう。
    • ネストしたリソースが(graphql-rubyが生成する)typesディレクトリにちゃんと分割されていればまあそこまで問題になることはないかもしれないが、resolveのブロック内のコードには割とプロジェクトによって自由度が発生して初見だと読みづらくなるような気がしている
  • N+1クエリの解決方法がいつもと違う感じになる
    • 普通はActiveRecordSQLのAST(Arel)を組み立て、そこにこのリソースをeager loadingするよという情報を埋め込むことでActiveRecord::Associations::PreloaderActiveRecord::Associations::JoinDependencyになんとかさせるんだけど、これは使わなくなる
    • かわりに、こういう感じでクエリのトラバース中に必要なIDを集めておいて、最後にgraphql-batchのミドルウェアを通してまとめてクエリすることになる。ネストしたリソースをクエリしてくるのに必要な情報を、親のリソースの一覧を参照することで取ってこれる限りはどうにかできるので、REST APIで問題にならないようなリクエスト(しかできないようにスキーマを制限した場合)ならGraphQLでも問題にならないような気がしている。しかし、例えばコントローラーから使うことを想定してN+1を解決するpreloaderを独自に書いていた場合は使えなくなる可能性があると思う。
    • graphql-batch相当のライブラリがない言語でやる場合、特にそれが静的型付け言語とかだとちょっと面倒かもしれない
  • graphql.jsを使う場合はFacebookBSD+Patentsライセンスに同意する必要がある

どちらでも大差のない好みの問題

仕様上の問題

  • REST APIのバージョン管理」 vs 「GraphQLの@deprecated
    • GraphQLのサイトにEvolve your API without versionsと書かれているが、個人的にはあまりこれが優れている点だと感じない。/v1/v2みたいなバージョンを更新していくかわりにフィールドに@deprecatedをつけていくと更新の粒度は細かくできるが、例えばStringだったfieldを同じ名前でObjectにしようとすると、REST APIなら新しいバージョンを1つ生やせば済むが、GraphQLの場合は同じ名前空間でやらないといけないので1度別のfieldを用意してそちらに移行し元のfieldを直すという2ステップ必要になる。どっちが楽かはどう変更していきたいかによる。
    • そもそも普通にアプリを書いているとRundeckみたいにAPIのバージョンをバシバシ上げる必要ってそんなに感じなくて、新たにfieldやエンドポイントを足すような後方互換性のある変更が多く、バージョンを上げるとしたらそれこそREST API→GraphQLくらい全体的に大きな変更がないとやらない気がしていて、もともとこれがそんなに問題ではない

現在のエコシステムの問題

  • JSON Schema」 vs 「GraphQLの型」
    • GraphQLは型があって便利! みたいな主張を見るが、別にその目的がAPIのparameterのvalidationやクライアント側がpropertyの型を知ることなら、別にREST APIでもJSON Schemaを書いてそれをJSONで返すエンドポイントでも生やしておけばいい。
      • APIを生やしたら必ずJSON Schemaもセットで書かないと(かつそれが実装にも使われていないと)気が済まない人にはGraphQLはマッチしそう
    • デフォルトで型があることによりツールチェインのアシストは最初から厚いと思うけど、それは完全にエコシステムの問題で、JSON Schemaに対して何かやったらいい
  • ドキュメントの自動生成のしやすさ
    • JSON Schema」 vs 「GraphQLの型」に付随する話。エンドポイントの名前やfieldの型以上の情報は人間が書く必要があり、これはどちらも変わらない。やりやすさはツールの実装依存
    • 「GraphQLだと型を書くことが強制される」こと自体は強みかもしれないが、型だけではドキュメントにならないし、REST APIでも各エンドポイントに対して必ずJSON Schemaを書かないといけなくなるようなテストでも用意すれば大体同じ状況になる。ドキュメント生成自体に関しては完全に利用する側の問題だと思っている。
  • ユーザーのパラメータ・レスポンスのプロパティにおける型安全性
    • GitHub APIのGraphQL化のモチベーションにWe wanted assurances of type-safety for user-supplied parametersがあげられているが、これも別にJSON Schema書いてそれをユーザー入力のバリデーションに使えばいい話で、別に必ずしもGraphQLの仕様が解決する話ではなく、単にRESTの時にサボっていただけと見ることができる

GraphQLの方がより良く解決している問題

仕様上の問題

  • いわゆるfields paramよりもインターフェースがより柔軟で記述力も高い
    • fields paramというのは?fields=id,name,..みたいに返すfieldを指定するパラメータのことを言っている。cookpad/garageにこういうのがあるのだが、miyagawaさん曰くこれはgraphQL になる前の Facebook graph API のやつをまねたらしいので、ちゃんとGraphQLになったものがより洗練されてるのは頷ける話
    • GraphQLのクエリは普通改行するが、スペース区切りでも書けるし、あまりネストしていないようなケースでそうした場合はリクエストする側のコードの見た目はどちらもあまり変わらない(見やすい)感じになる。
  • APIのリクエスト数やround trip timeを減らしやすくなる
    • 「ありとあらゆるリソースをリクエスト一発で取得できる夢の技術ではない」と書いたが、あるリソースに対して1対1の関係でネストしたリソースも同時に取ってくるのにはGraphQLはとても向いている。もちろんREST APIでもそういうエンドポイントは生やせるが、GraphQLの方が自然かつ重複なく実装できると思う。
  • 余計なfieldのリクエストが減りやすい
  • 各fieldをクライアントが使っていないことを明示できるので、fieldの利用状況を調べやすい
    • REST APIでも?fields=id,name,..みたいなパラメータを用意し、それを使うのを強制すれば解決できないことはない。が、実際毎回書くのは面倒なのでgarageだとfieldsを指定しなかったりするとデフォルトの奴を返せたりするし、人間は楽な方に流れがち。仕様レベルで強制されるGraphQLの方がこの点は良いはず。フレームワーク次第ではREST APIでもこれは解決できる。

現在のエコシステムの問題

  • クライアントキャッシュを実装するためのGlobally Unique IDsなどガイドラインが示されており、実際にRelayのようなそれを念頭に置き活用するフレームワークが存在する
    • この辺にRelayの仕組みが書いてあるが、RelayからReactを使うと、ユニークなIDをキーにnodeをクライアントサイドにキャッシュし、クエリをトラバースして本当に必要な場所だけクエリされるようにすることが可能らしい。REST APIでもクライアントキャッシュは実装しようがあるが、こちらの方がより細かい粒度で柔軟にキャッシュができると思う。
  • クエリのIDE的な機能を持つGraphiQLに型がちゃんと活用されている
    • そのため、毎回必要なfieldを指定しないといけない割には、クエリを書くのはそこまで苦痛ではない
    • 一方でGraphiQLはいろいろキーバインドが潰されてるので普通のテキストのエディットはとてもやりにくい

GraphQLではないと解決できない問題

  • 思いつかなかった

まとめ

GraphQLを使う場合の前提条件として、HTTPキャッシュを使わないケース*1である必要があり、また現時点だとGraphQL Proに$900/yearを払うかAPIの詳細なモニタリングを諦める必要がある。

その上で、サーバー側に型の記述を強制しクライアント側にfieldの記述を強制することにより、以下の例のように双方が幸せになると判断した場合は好みに応じて使えばいいと思う。REST APIかGraphQLのどちらかを使わないとすごく困るという状況は上記の前提以外はあまりなさそう。

GraphQLが向いてそうなケース

  • サーバーの実装者とクライアント実装者の距離が遠く、サーバー実装者の想定と異なるAPIの使い方が想定できる時に、余計なリソースを返したり不要にリクエスト数が増えるのを防止したり、消したいfieldが使われ続けるのを@deprecatedにより抑止したり、その利用現状を調査したりしたい場合
  • Reactでクライアントを構築しており、Relay等のフレームワークとGraphQL*2を用いてリソース単位のクライアントサイドキャッシュを実装することで、サーバーやデータベースへの負荷が最小限に抑えられることが期待できる場合

具体的にはGitHubが1つ目に該当してFacebookが2つ目に該当したんじゃないかなあと思っている。

*1:取得されるリソースがリクエストごとに分散しており、あまりHTTPキャッシュが役に立たない場合。あるいは(Relayなどでクライアント側にキャッシュすることも考慮して)そもそもそんなに多くのリクエストが来ないので不要な場合。

*2:全部同じ会社から出てるので相性はよくて当然

CRuby向けのLLVMベースのJITコンパイラを書いている話

LLRBというRuby向けのメソッドJITコンパイラを書いている

github.com

RubyKaigi 2015の最後のキーノート@evanphxが「LLVMでCRubyのコードをインライン化するメソッドJITを実装したら速いんじゃね」みたいな発表をしていたのを覚えているだろうか。

LLRBというのはまさにそれを実装しているプロジェクトであり、少なくとも現時点で「LLVMでCRubyのコードをインライン化するメソッドJIT」と言える状態まで実装でき、ものによっては効果が出る状態になったので公開した。

なんで書いてるの

言語を自分で実装するとその言語に関する理解が大分深まる、というのをHamlの実装とかCコンパイラとかで体験していて、僕が一番好きな言語はRubyなのでRubyでもそれをやっておきたい、というのがあった。また、Rubyは遅いと言われがちだが、どこに改善可能な点が眠っているのかにも興味がある。

また、ささださんがRuby3のゴールの一つにJust-in-Time compilationをあげていて、そのスライドで1つの選択肢として言われていた"Use LLVM"というパートを検証するものでもある。ただし、MatzがLLVMはDependency的に微妙と言っており、本体にこの方向性で入ることは多分なく、あくまで実験的なプロジェクトになる。

どのように動くか

READMEを書いてたら大分疲れたので、この記事はその日本語訳にしておく。

LLRBのビルドプロセスでは、CRubyの命令やそれに使われるいくつかの関数をLLVM bitcodeという形式にプリコンパイルしている。

 ________     _________     ______________
|        |   |         |   |              |
| CRuby  |   | CRuby   |   | CRuby        |
| C code |-->| LLVM IR |-->| LLVM bitcode |
|________|   |_________|   |______________|

LLRBのJITのためのプロファイラが開始されると、Rubyの実行中にJITコンパイルがスケジューリングされる。 JITコンパイルの際、LLRBはYARV ISeqをLLVM IRにコンパイルするが、その際にプリコンパイルしておいたLLVM bitcodeをリンクするので、LLVM Passによる関数のインライン化やその後の最適化が行なえる。

最適化を行なった後、LLVM IRはLLVMのMCJITエンジンによって機械語に変換され、それを呼び出すためのCの関数ポインタとしてLLRBに返される。コンパイル対象のISeqに対し、後に説明する方法によって変化が加えられ、この関数ポインタが実行されるようになり最適化が完了する。

 ______     ______________      __________      ___________      _________
|      |   |              |    |          |    |           |    |         |
| YARV |   | ISeq Control |    | LLVM IR  |    | Optimized |    | Machine |
| ISeq |-->| Flow Graph   |-*->| for ISeq |-*->| LLVM IR   |-*->| code    |
|______|   |______________| |  |__________| |  |___________| |  |_________|
                            |               |                |
                            | Link          | Optimize       | JIT compile
                      ______|_______     ___|____          __|____
                     |              |   |        |        |       |
                     | CRuby        |   | LLVM   |        | LLVM  |
                     | LLVM Bitcode |   | Passes |        | MCJIT |
                     |______________|   |________|        |_______|

実際にパフォーマンスが向上するか?

基本的なインライン化は実装済なので、その効果を見ていく。

以下のようなruby/benchmark/bm_loop_whileloop.rbのコードについて考える。

def while_loop
  i = 0
  while i<30_000_000
    i += 1
  end
end

このメソッドのYARV ISeq (LLRBのコンパイル対象) はこうなっている。

> puts RubyVM::InstructionSequence.of(method(:while_loop)).disasm
== disasm: #<ISeq:while_loop@(pry)>=====================================
== catch table
| catch type: break  st: 0015 ed: 0035 sp: 0000 cont: 0035
| catch type: next   st: 0015 ed: 0035 sp: 0000 cont: 0012
| catch type: redo   st: 0015 ed: 0035 sp: 0000 cont: 0015
|------------------------------------------------------------------------
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] i
0000 trace            8                                               (   1)
0002 trace            1                                               (   2)
0004 putobject_OP_INT2FIX_O_0_C_
0005 setlocal_OP__WC__0 3
0007 trace            1                                               (   3)
0009 jump             25
0011 putnil
0012 pop
0013 jump             25
0015 trace            1                                               (   4)
0017 getlocal_OP__WC__0 3
0019 putobject_OP_INT2FIX_O_1_C_
0020 opt_plus         <callinfo!mid:+, argc:1, ARGS_SIMPLE>, <callcache>
0023 setlocal_OP__WC__0 3
0025 getlocal_OP__WC__0 3                                             (   3)
0027 putobject        30000000
0029 opt_lt           <callinfo!mid:<, argc:1, ARGS_SIMPLE>, <callcache>
0032 branchif         15
0034 putnil
0035 trace            16                                              (   6)
0037 leave                                                            (   4)
=> nil

LLRBのコンパイラは、これを以下のようなLLVM IRにコンパイルする。

define i64 @llrb_exec(i64, i64) {
label_0:
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 8, i64 52)
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 1, i64 52)
  call void @llrb_insn_setlocal_level0(i64 %1, i64 3, i64 1)
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 1, i64 52)
  br label %label_25

label_15:                                         ; preds = %label_25
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 1, i64 52)
  %2 = call i64 @llrb_insn_getlocal_level0(i64 %1, i64 3)
  call void @llrb_set_pc(i64 %1, i64 94225474387824)
  %opt_plus = call i64 @llrb_insn_opt_plus(i64 %2, i64 3)
  call void @llrb_insn_setlocal_level0(i64 %1, i64 3, i64 %opt_plus)
  br label %label_25

label_25:                                         ; preds = %label_15, %label_0
  %3 = call i64 @llrb_insn_getlocal_level0(i64 %1, i64 3)
  call void @llrb_set_pc(i64 %1, i64 94225474387896)
  %opt_lt = call i64 @llrb_insn_opt_lt(i64 %3, i64 60000001)
  %RTEST_mask = and i64 %opt_lt, -9
  %RTEST = icmp ne i64 %RTEST_mask, 0
  br i1 %RTEST, label %label_15, label %label_34

label_34:                                         ; preds = %label_25
  call void @llrb_insn_trace(i64 %0, i64 %1, i32 16, i64 8)
  call void @llrb_set_pc(i64 %1, i64 94225474387960)
  call void @llrb_push_result(i64 %1, i64 8)
  ret i64 %1
}

上記のLLVM IRでcallされている関数は全てLLVM bitcodeにプリコンパイルされており、LLRBはJITコンパイル時にそれをリンクする。そのため以下のようなインライン化と最適化がLLVMによって行なわれる。

define i64 @llrb_exec(i64, i64) #0 {
  ...

land.lhs.true.i:                                  ; preds = %label_25
  %49 = load %struct.rb_vm_struct*, %struct.rb_vm_struct** @ruby_current_vm, align 8, !dbg !3471, !tbaa !3472
  %arrayidx.i = getelementptr inbounds %struct.rb_vm_struct, %struct.rb_vm_struct* %49, i64 0, i32 39, i64 7, !dbg !3471
  %50 = load i16, i16* %arrayidx.i, align 2, !dbg !3471, !tbaa !3473
  %and2.i = and i16 %50, 1, !dbg !3471
  %tobool6.i = icmp eq i16 %and2.i, 0, !dbg !3471
  br i1 %tobool6.i, label %if.then.i, label %if.else11.i, !dbg !3475, !prof !3380

if.then.i:                                        ; preds = %land.lhs.true.i
  call void @llvm.dbg.value(metadata i64 %48, i64 0, metadata !2680, metadata !3361) #7, !dbg !3477
  call void @llvm.dbg.value(metadata i64 60000001, i64 0, metadata !2683, metadata !3361) #7, !dbg !3478
  %cmp7.i = icmp slt i64 %48, 60000001, !dbg !3479
  %..i = select i1 %cmp7.i, i64 20, i64 0, !dbg !3481
  br label %llrb_insn_opt_lt.exit

if.else11.i:                                      ; preds = %land.lhs.true.i, %label_25
  %call35.i = call i64 (i64, i64, i32, ...) @rb_funcall(i64 %48, i64 60, i32 1, i64 60000001) #7, !dbg !3483
  br label %llrb_insn_opt_lt.exit, !dbg !3486

llrb_insn_opt_lt.exit:                            ; preds = %if.then.i, %if.else11.i
  %retval.1.i = phi i64 [ %..i, %if.then.i ], [ %call35.i, %if.else11.i ]
  %RTEST_mask = and i64 %retval.1.i, -9
  %RTEST = icmp eq i64 %RTEST_mask, 0

  ...
}

いろいろインライン化されている上に僕の書いたCSSがクソなので読みづらいが、簡単に説明するとRubyVMの状態を取得し、<が再定義されているかどうかをチェックし、再定義されていなければicmp sltという命令が実行されるようになっている。インライン化されているので、llrb_insn_opt_ltという本来の関数の呼び出しのオーバーヘッドもなくなっている。 これはYARVのinsns.defの定義をほとんどそのまま持ってきてインライン化しているだけなので、実装コストが低いというメリットがある。

この最適化で、以下のようなベンチマークで、

ruby = Class.new
def ruby.script
  i = 0
  while i< 30_000_000
    i += 1
  end
end

llrb = Class.new
def llrb.script
  i = 0
  while i< 30_000_000
    i += 1
  end
end

LLRB::JIT.compile(llrb, :script)

Benchmark.ips do |x|
  x.report('Ruby') { ruby.script }
  x.report('LLRB') { llrb.script }
  x.compare!
end

以下のようにパフォーマンスが改善することがわかる。

# x86_64 GNU/Linux, Intel(R) Core(TM) i7-4790K CPU @ 4.00GHz
Calculating -------------------------------------
                Ruby      2.449  (± 0.0%) i/s -     13.000  in   5.308125s
                LLRB      8.533  (± 0.0%) i/s -     43.000  in   5.040016s

Comparison:
                LLRB:        8.5 i/s
                Ruby:        2.4 i/s - 3.48x  slower

LLRBの設計はどうなっているか

C拡張として作成

どうやらYARVは最初C拡張として作られていたらしい。JITコンパイラをC拡張として作るのは大分無理があると思ってたけど、YARVがC拡張でできるならJITコンパイラもC拡張でできるのではないかと思ったのでそうしてみた。実際、CRubyのforkとして開発をするのに比べると、コアと疎結合になるだけでなく、bundlerとかbenchmark-ipsとかその辺のgemをカジュアルに使えるので開発はやりやすい。

しかし実際のところ、基本的な演算子のメソッドが再定義されてるかどうかを持つ変数のシンボルが当然exportされてないことに気付き、そこでCRubyに変更を加えず開発することは諦めた。ので、k0kubun/rubyのllrbブランチにしかインストールできない。それでも、シンボルのexport以外には何もしないという縛りを設けてあるので、今後CRubyのバージョンが上がっても追従はしやすい気がする。

YARVに手を加えない保守的なアプローチ

YARVは大分長い間運用されている信頼性のあるVMなので、仮にJITコンパイラを導入したとしても移行期間中はベースになるYARVがそのまま使える状態の方が安心できると思っている。なので、LLRBはYARV ISeqからコンパイルする方式を取り、またYARVのコア自体には一切変更を加えないようになっている。

じゃあそれでどうやってJITを達成しているかというと、YARVにはopt_call_c_functionという命令があり、「ネイティブコンパイルしたメソッドを起動。」という説明が書いてある。これをJITに使わない理由はない。

というわけで、LLRBは前述した方法でCの関数ポインタを取得してそれをopt_call_c_functionオペランドとし、全てのメソッドを以下のようなISeqに変換する。

0000 opt_call_c_function
0002 leave

opt_call_c_functionYARVの内部例外を扱える *1ので、この命令を使えば大抵のことは実装できるし、少なくとも僕がテストを書いた範囲ではthrowとかbreakとかreturnが動いている。

しかし、ISeqを書き変える方法だと考慮しないといけないことがある。それは、YARVが内部例外を補足するcatch tableがプログラムカウンターの位置に依存して内部例外をハンドルすることである。 プログラムカウンターが0か2にいればすむようにcatch table自体を書き変えると分岐できなくなるケースがありそうだったので、LLRBはcatch tableには手を加えず、逆にネイティブコードの中でプログラムカウンターを従来通り動かす方を選択した。

なので、プログラムカウンターがそのような位置にあっても適切にleaveできるよう、余った位置にleave命令埋めをしている。これはshyouheiさんのDeoptimization Engineのnop埋めのパクリである。

正直それで問題が起きないのかはよくわかってないので他の人のツッコミ待ちだが、僕がテストを書いた範囲ではrescueとかensureが動いている。

サンプリングベースの軽量なプロファイラ

LLRBはプロファイラとしてサンプリングプロファイラを採用している。CPU時間で一定のインターバルおきにバックトレースのサンプリングを行ない、バックトレースの一番上に出現する頻度が高いものをコンパイル対象として選択する。

stackprofとかperfもサンプリングプロファイラであり、この手法自体は既に広く使われているため信頼性がある。

また、stackprofと同じくrb_postponed_job_register_one APIを使う + GC実行中かどうかを判定していて、おそらくコンパイルしても安全と思われる瞬間にコンパイルが行なわれる。

低いコンパイルコスト

上述したように、CRubyのC関数はビルドプロセスでLLVM bitcodeにプリコンパイルされているので、LLRBがJITコンパイルを実行する際ランタイムでCの関数のパースやコンパイルを行なうオーバーヘッドは発生しない。 また、RubyのASTからコンパイルするわけではなく、ある程度コンパイルされたYARV ISeqからコンパイルを開始しているので、おそらくこれが一番コンパイルコストが低い手法になると思われる。

TODOs

  • まだ全ての命令に対して最適化を実施できていないので、実際のアプリケーションで使うとパフォーマンスが劣化する。なので、まずはその対応から…
  • ISeqのインライン化に対応したい
  • まだコンパイルに対応してないYARV命令がいくつかある
    • expandarray, reverse, reput, defineclass, once, opt_call_c_function
  • コンパイル中に作るRubyのオブジェクトのうちGCを考慮できてない奴がある気がする

ビルド・使用方法

このへんを参照

気持ち

まだ大分未完成なんだけど、3か月くらい経ちモチベーションの維持が難しくなってきたので公開した。この辺で得られた知見をRubyKaigiとかで話せたらいいなと思っていたけど、まだ僕のCFPはacceptされていないので、まあ無理だったらRubyConfで会いましょう。

*1:YARV内部の例外についてはRuby under a microscopeとかに詳しく書いてあるのでここでは説明しない