YARV-MJIT のデバッグをしながらその過程を文章にしたらどうなるか、という文です。 Advent Calendar とかどこかに書こうかと思っていたんですがだいたいどこも埋まっていたので、そういうのとは無関係に書いていきます。
CRuby の JIT の一実装で、https://github.com/k0kubun/yarv-mjit で k0kubun さんという方が開発されています。
https://speakerdeck.com/k0kubun/rails-developers-meetup-2017 ここにわかりやすく書かれています。
試しに動作させるために、ビルドします。 普通の ruby をソースからビルドするのとあまり変わらないので、このあたりは細かく説明しません。
- 環境を用意する
私は Linux (Ubuntu 17.10)で試しました。 Mac でも動くと思うのですが試していません。 Windows ではいろいろ難しそうな気がしますが、MinGW の対応が順次入っているので動くかもしれません。
「普通の ruby をソースからビルドするのとあまり変わらない」と書きましたが、パッケージング済みの ruby と比べると autoconf や bison やビルド用の ruby などが必要になるので注意してください。
- ソースを取得する
$ git clone https://github.com/k0kubun/yarv-mjit.git
$ cd yarv-mjit/
- ビルドする
prefix 以降の部分には、インストールしたいパスを指定します。 rbenv で管理したいのでそういったパスを指定していますがこの部分は自由です。
$ autoconf
$ ./configure --prefix=`ls -d ~/.rbenv/versions/yarv-mjit`
$ make -j4 REVISION_FORCE=PHONY install-nodoc
-j をつけると JIT が有効になります。 まずは JIT 無効状態、次に JIT 有効状態の順に実行してみます。
$ time ruby -e 'def f(n); return n if n <= 1; f(n - 1) + f(n - 2); end; p f(38)'
39088169
real 0m3.885s
user 0m3.841s
sys 0m0.044s
$ time ruby -j -e 'def f(n); return n if n <= 1; f(n - 1) + f(n - 2); end; p f(38)'
39088169
real 0m2.166s
user 0m4.094s
sys 0m0.204s
実時間で 3.8 秒 -> 2.2 秒と高速化しています。すごい。 user 時間が増えているのは、JIT 有効時だと ruby の普通のプロセスと JIT のためのプロセスが並列に動作するためです。
YARV-MJIT の動作をとても雑に説明すると、
- たくさん(5 回以上)実行されたブロックやメソッドを、
- 裏でスレッドで動いている MJIT ワーカーに渡して、
- ワーカーが切り貼りして C のソースコードを生成して、
- C コンパイラ(gcc または clang)でコンパイルして、
- .so ファイルとして読み込んで、
- もともとのブロックやメソッドと差し替える
という感じです。 コンパイルは ruby とは無関係に進むので、この部分は並列処理されます。
まれに、JIT を有効にしても速くならない、それどころか遅くなることがあります
$ time ruby -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
39088169
real 0m4.662s
user 0m4.627s
sys 0m0.037s
$ time ruby -j -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
39088169
real 0m5.957s
user 0m11.526s
sys 0m0.447s
4.7 秒 -> 6.0 秒と悪化しています。 なにがあったのでしょうか。
こういったとき、何があったのか調べるためには、JIT に関する出力を表示させる "-j:v=n" オプションが便利です。
n の部分には 1-4 の数字が入り、どの程度詳しい情報を出力するかを決めることができます。
詳しくは、ruby --help コマンドで出力される "MJIT options:" という項目を各自確認してください。
このとき、rubygems を有効にしていると rubygems の JIT コンパイル結果が出てきてわかりにくくなるので、同時に "--disable-gems" オプションもつけるのがよいでしょう。
$ time ruby --disable-gems -j:v=1 -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
MJIT: CC defaults to gcc
/tmp/_mjitp21406u0.c: In function ‘_mjit0’:
/tmp/_mjitp21406u0.c:22:5: error: duplicate case value
case 38:
^~~~
compilation terminated due to -Wfatal-errors.
39088169
Successful MJIT finish
real 0m4.884s
user 0m5.025s
sys 0m0.032s
"duplicate case value", 訳すと「case の値が重複しています」ということになると思います。
これだけだとよくわからないのでソースコードが見たいのですが、MJIT は普段は作成したソースコードも .so も自動で削除します。 削除しないようにするには、"-j:s" オプションを使います。
$ time ruby --disable-gems -j:v=1 -j:s -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
MJIT: CC defaults to gcc
/tmp/_mjitp22204u0.c: In function ‘_mjit0’:
/tmp/_mjitp22204u0.c:22:5: error: duplicate case value
case 38:
^~~~
compilation terminated due to -Wfatal-errors.
39088169
Successful MJIT finish
real 0m5.022s
user 0m5.176s
sys 0m0.026s
エラー表示の中にある /tmp/_mjitp22204u0.c というファイルを見てみますと、
#include "/tmp/_mjit_hp22204u0.h"
/* f@-e:1 */
VALUE _mjit0(rb_execution_context_t *ec, rb_control_frame_t *cfp) {
VALUE stack[4];
(中略)
label_3: /* opt_case_dispatch */
cfp->pc = (VALUE *)0x564c25cd63c8;
switch (vm_case_dispatch(0x564c25c59fa0, 0xc, stack[1])) {
case 38:
goto label_44;
break;
case 38:
goto label_44;
break;
}
こんな感じで、確かに case 文の値が重複している部分があることがわかります。 C 言語ではこれは文法違反なんですね。
上の雑な説明では、「ワーカーが切り貼りして C のソースコードを生成して」と書きました。 このソースコード生成に不具合があるらしい、ということがわかります。 さてではどこかというと、ソースコードの生成部分はすべて mjit_compile.c というファイルにまとめられています。 さらに上で確認したファイルに書かれていた "/* opt_case_dispatch */" というコメントを頼りに見てみます。
case YARVINSN_opt_case_dispatch:
{
struct case_dispatch_var arg;
arg.f = f;
arg.base_pos = pos + insn_len(insn);
fprintf(f, " switch (vm_case_dispatch(0x%"PRIxVALUE", 0x%"PRIxVALUE", stack[%d])) {\n", operands[0], operands[1], --b->stack_size);
rb_hash_foreach(operands[0], compile_case_dispatch_each, (VALUE)&arg);
fprintf(f, " }\n");
}
break;これは compile_insn() という関数の一部です。
この関数は「CRuby の VM であるところの YARV の命令 1 に対して、対応する C のソースコードをファイルに出力する」というような動きをします。
ややこしいですね。「VM 命令 -> C のコード」の変換をすると覚えておけばだいたい大丈夫です。
case YARVINSN_opt_case_dispatch つまり YARV の命令の opt_case_dispatch をコンパイルするときには、まず switch 文を C のソースとしてファイルに書き出し、opt_case_dispatch の第一引数 operands[0] に入っているハッシュに対して compile_case_dispatch_each を呼び出し、最後に中括弧 } を出力する、というようなことが書かれています。
ということは肝心の case 文はこの部分に書かれていないので、次は飛び先の compile_case_dispatch_each を見てみます。
static int
compile_case_dispatch_each(VALUE key, VALUE value, VALUE arg)
{
struct case_dispatch_var *var = (struct case_dispatch_var *)arg;
unsigned int offset = FIX2INT(value);
fprintf(var->f, " case %d:\n", offset);
fprintf(var->f, " goto label_%d;\n", var->base_pos + offset);
fprintf(var->f, " break;\n");
return ST_CONTINUE;
}case 文を出力している箇所にたどり着きました。 value として連続で同じ値が渡されると、たしかに同じ case 文が出力されそうです。
なお、なぜ同じ値が出力されるのかというと、JIT コンパイルしようとした Ruby スクリプトの中の "when 0, 1" という部分が問題です。 n の値が 0 のときと 1 のときで、「n を返り値にする」という同じ処理がされるので、goto の飛び先も同じなのでした。
二回目以降にこの関数を呼び出すときに、value の値が前回と同じだったら出力をしない、というようにすると、先ほどのエラーは回避できそうです。
値が A->B->...->A のようになるとこの回避方法は使えませんが、こうはならないことが保証されています。
これは compile.c の compile_case() という関数でのハッシュの生成方法がそうなっているからなのですが、MJIT の話とはだいぶずれるのでここでは触れません。興味のある方はソースを読んでみてください。
ということでできあがったパッチがこちら。
diff --git a/mjit_compile.c b/mjit_compile.c
index ced7d40398..54b02bcf1f 100644
--- a/mjit_compile.c
+++ b/mjit_compile.c
@@ -249,17 +249,22 @@ fprint_opt_call_with_key(FILE *f, VALUE ci, VALUE cc, VALUE key, unsigned int st
struct case_dispatch_var {
FILE *f;
unsigned int base_pos;
+ VALUE last_value;
};
static int
compile_case_dispatch_each(VALUE key, VALUE value, VALUE arg)
{
struct case_dispatch_var *var = (struct case_dispatch_var *)arg;
- unsigned int offset = FIX2INT(value);
-
- fprintf(var->f, " case %d:\n", offset);
- fprintf(var->f, " goto label_%d;\n", var->base_pos + offset);
- fprintf(var->f, " break;\n");
+ unsigned int offset;
+
+ if (var->last_value != value) {
+ offset = FIX2INT(value);
+ var->last_value = value;
+ fprintf(var->f, " case %d:\n", offset);
+ fprintf(var->f, " goto label_%d;\n", var->base_pos + offset);
+ fprintf(var->f, " break;\n");
+ }
return ST_CONTINUE;
}
@@ -635,6 +640,7 @@ compile_insn(FILE *f, const struct rb_iseq_constant_body *body, const int insn,
struct case_dispatch_var arg;
arg.f = f;
arg.base_pos = pos + insn_len(insn);
+ arg.last_value = Qundef;
fprintf(f, " switch (vm_case_dispatch(0x%"PRIxVALUE", 0x%"PRIxVALUE", stack[%d])) {\n", operands[0], operands[1], --b->stack_size);
rb_hash_foreach(operands[0], compile_case_dispatch_each, (VALUE)&arg);これで再度ビルド、実行してみます。
$ make -j4 REVISION_FORCE=PHONY install-nodoc
$ time ruby --disable-gems -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
39088169
real 0m4.597s
user 0m4.549s
sys 0m0.033s
$ time ruby --disable-gems -j:v=1 -j:s -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
MJIT: CC defaults to gcc
JIT success (97.0ms): f@-e:1 -> /tmp/_mjitp25958u0.c
39088169
Successful MJIT finish
real 0m5.320s
user 0m5.573s
sys 0m0.041s
あれ、JIT コンパイルには成功しているはずなのですが、なぜかやっぱり速くなっていません。 なんでしょうね。
出力された /tmp/_mjitp25958u0.c を読み直してみます。
#include "/tmp/_mjit_hp25958u0.h"
/* f@-e:1 */
VALUE _mjit0(rb_execution_context_t *ec, rb_control_frame_t *cfp) {
VALUE stack[4];
(中略)
label_3: /* opt_case_dispatch */
cfp->pc = (VALUE *)0x55d487d1c9a8;
switch (vm_case_dispatch(0x55d487c9df90, 0xc, stack[1])) {
case 38:
goto label_44;
break;
}
label_6: /* dup */
cfp->pc = (VALUE *)0x55d487d1c9c0;
stack[1] = stack[0];
label_7: /* putobject_OP_INT2FIX_O_0_C_ */
cfp->pc = (VALUE *)0x55d487d1c9c8;
stack[2] = INT2FIX(0);
label_8: /* checkmatch */
cfp->pc = (VALUE *)0x55d487d1c9d0;
stack[1] = vm_check_match(ec, stack[1], stack[2], 0x2);
label_10: /* branchif */
cfp->pc = (VALUE *)0x55d487d1c9e0;
if (RTEST(stack[1])) {
RUBY_VM_CHECK_INTS(ec);
goto label_44;
}さきほど触れませんでしたが、vm_case_dispatch() というのは「opt_case_dispatch の第一引数のハッシュを用いて、case ... when のどこを処理するか、どの箇所に飛ぶかを決める」という動きをする ruby 内部の関数です。
そうすると、case 38: goto label_44; の部分は Ruby スクリプトの "when 0, 1" の後の処理らしい、またそれ以降の処理は「そうでない場合」つまり "else" の後の処理らしい、ということがわかります。
……が、else 以降の部分は "f(n - 1) + f(n - 2)" という形になっているはずで、checkmatch つまり === での比較判定や、branchif つまり分岐処理のようなものはないはずです。
どうなっているのでしょうか。
さきほど vm_case_dispatch() の説明で「opt_case_dispatch の第一引数のハッシュを用いて、case ... when のどこを処理するか、どの箇所に飛ぶかを決める」と書きましたが、これは正確ではありません。
「opt_case_dispatch の第一引数のハッシュを用いて、case ... when のどこを処理するか、どの箇所に飛ぶか、静的に決められる場合には決める」といった方がより正確です。
決められる場合というのは when 0, 1 のように値がリテラルで指定されている場合で、決められない場合というのは when a のように変数やメソッドなどで指定されていてその部分を実行しないと値がわからない場合です。
このように、vm_case_dispatch() で必ず飛び先がわかるわけではないので、元々の case 文を愚直に確認する処理も残っている必要があります。
それが上の checkmatch や branchif の部分です。
そういうことであれば、「確実に when 0, 1 以下の処理に進む場合」「確実に else 以降に処理が進む場合」「vm_case_dispatch() ではわからなかった場合」で場合分けすればいいことになります。(今回のケースでは vm_case_dispatch() でわからないことはないはずですが、そうでないケースがあることは前述のとおりです)
これをふまえて修正した結果、パッチはこうなりました。
diff --git a/mjit_compile.c b/mjit_compile.c
index cc07bfeb83..f91e752378 100644
--- a/mjit_compile.c
+++ b/mjit_compile.c
@@ -644,8 +644,8 @@ compile_insn(FILE *f, const struct rb_iseq_constant_body *body, const int insn,
fprintf(f, " switch (vm_case_dispatch(0x%"PRIxVALUE", 0x%"PRIxVALUE", stack[%d])) {\n", operands[0], operands[1], --b->stack_size);
rb_hash_foreach(operands[0], compile_case_dispatch_each, (VALUE)&arg);
- fprintf(f, " case %d:\n", operands[1]);
- fprintf(f, " goto label_%d;\n", arg.base_pos + operands[1]);
+ fprintf(f, " case %lu:\n", operands[1]);
+ fprintf(f, " goto label_%lu;\n", arg.base_pos + operands[1]);
fprintf(f, " }\n");
}
break;さあもう一度実行してみます。
$ make -j4 REVISION_FORCE=PHONY install-nodoc
$ time ruby --disable-gems -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
39088169
real 0m4.774s
user 0m4.737s
sys 0m0.039s
$ time ruby --disable-gems -j:v=1 -j:s -e 'def f(n); case n; when 0, 1; n; else f(n - 1) + f(n - 2); end; end; p f(38)'
MJIT: CC defaults to gcc
JIT success (97.4ms): f@-e:1 -> /tmp/_mjitp1724u0.c
39088169
Successful MJIT finish
real 0m3.007s
user 0m3.276s
sys 0m0.050s
期待通り速くなりました。
こんなに行き当りばったりでもなんとかなるのが、C のソースコードしか扱わないで済む YARV-MJIT のいいところだと、個人的には思います。 ということで出来上がったものが k0kubun/yarv-mjit#22 こちらになります。