Skip to content

Instantly share code, notes, and snippets.

@wanabe
Last active December 10, 2017 12:45
Show Gist options
  • Select an option

  • Save wanabe/c3f98c1b18ded5e9013e98ad44e203fe to your computer and use it in GitHub Desktop.

Select an option

Save wanabe/c3f98c1b18ded5e9013e98ad44e203fe to your computer and use it in GitHub Desktop.
YARV-MJIT のデバッグをしながら内部を見る

この文章は

YARV-MJIT のデバッグをしながらその過程を文章にしたらどうなるか、という文です。 Advent Calendar とかどこかに書こうかと思っていたんですがだいたいどこも埋まっていたので、そういうのとは無関係に書いていきます。

YARV-MJIT とは

CRuby の JIT の一実装で、https://github.com/k0kubun/yarv-mjit で k0kubun さんという方が開発されています。

https://speakerdeck.com/k0kubun/rails-developers-meetup-2017 ここにわかりやすく書かれています。

ビルドしてみる

試しに動作させるために、ビルドします。 普通の ruby をソースからビルドするのとあまり変わらないので、このあたりは細かく説明しません。

  1. 環境を用意する

私は Linux (Ubuntu 17.10)で試しました。 Mac でも動くと思うのですが試していません。 Windows ではいろいろ難しそうな気がしますが、MinGW の対応が順次入っているので動くかもしれません。

「普通の ruby をソースからビルドするのとあまり変わらない」と書きましたが、パッケージング済みの ruby と比べると autoconf や bison やビルド用の ruby などが必要になるので注意してください。

  1. ソースを取得する
$ git clone https://github.com/k0kubun/yarv-mjit.git
$ cd yarv-mjit/
  1. ビルドする

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 */" というコメントを頼りに見てみます。

https://github.com/k0kubun/yarv-mjit/blob/f62ed525ee4b1c2aa179b370ace3f2c24d81763b/mjit_compile.c#L633

      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 を見てみます。

誰がこれを書いたのか(2)

https://github.com/k0kubun/yarv-mjit/blob/f62ed525ee4b1c2aa179b370ace3f2c24d81763b/mjit_compile.c#L254

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 つまり分岐処理のようなものはないはずです。 どうなっているのでしょうか。

唐突に YARV の話

さきほど 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 こちらになります。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment