mrubyのOP_SENDとブロック引数
いまのところ興味はVMのパフォーマンスのみ。昨日ためしたことを忘れないうちに残しておく。まずちょっと気になったFloatオブジェクトのサイズから。
mrubyはmrbconf.hを変更することで設定を変えてコンパイルすることができる。正規表現や標準入出力などを使うかどうか、といった話だが、ここにFloatオブジェクトのサイズというか精度を指定するのも存在する。
#ifdef MRB_USE_FLOAT typedef float mrb_float; #else typedef double mrb_float; #endif
デフォルトはCRubyと同じようにdoubleを使う。
VMの処理はそのほとんどがオブジェクト(mrubyのmrb_value、CRubyでいうVALUE型みたいなもの)のコピーとメソッド呼び出しなのではないかと言う気がしている。
mrb_valueの型はこうなっている。
typedef struct mrb_value { union { mrb_float f; void *p; mrb_int i; mrb_sym sym; } value; enum mrb_vtype tt:8; } mrb_value;
64bit環境なら全部64bitで全部揃う。が、32bit環境だとmrb_floatだけが64bitになってしまう。unionなのでmrb_valueのサイズは64+8bitになり、例えばpとかiとかのみコピーする場合は32bitの読み書きになるが、mrb_valueをコピーするときは64+8bitのコピーが行われることになる。昨日のアセンブラコードで4+4+1byteコピーされていたので気づいた。
VMはmrb_valueをレジスタにコピーする処理が大変多いので、MRB_USE_FLOATを定義することで全体的なパフォーマンスがよくなるはずだ。
試してみた。CRubyについてくるbm_so_lists.rbを実行して比較。下がfloat型にしたほう。
D:\test>mruby bm_so_lists.rb 4.4375 D:\test>mruby_test bm_so_lists.rb 3.484375
ずいぶん変わる。Windowsでゲームとか組み込み向けとかやる人は64bit環境ではないだろうしdouble型の精度など必要ないことが多いだろうからこれはしっかりと定義しておいたほうがいい。
長くなってしまったが次が本題。
VM命令セットにコンパイルしたコードを眺めると、OP_SENDの前にレジスタに値を入れる命令が並ぶ。たとえばp (1+2)とするとこんなコードになる。
irep 148 nregs=5 nlocals=2 pools=0 syms=2 000 OP_LOADSELF R2 001 OP_LOADI R3 1 002 OP_LOADI R4 2 003 OP_LOADNIL R5 004 OP_ADD R3 :+ 1 005 OP_LOADNIL R4 006 OP_SEND R2 :p 1 007 OP_STOP
OP_ADDやOP_SENDに渡すレジスタはレシーバを入れるレジスタで、そこから順に引数・ブロックを連続したレジスタに入れてからOP_ADDやOP_SENDを実行する。シンボルはメソッド名、シンボルの後ろの値は引数の数だ。
メソッドに引数を渡すのは理解できるが、何かを呼び出すたびにブロックが無いことを表すOP_LOADNILが出力されているのが目立つ。これが気になるのはメソッドの呼び出しのほとんどはブロックを持たないからだ。命令自体はシンプルで速くても、Rubyはメソッド呼び出しの塊なのだから、そのたびにOP_LOADNILが入ると無視できない数になる。気がする。
OP_LOADNILのコードはこんなかんじ。
#define SET_NIL_VALUE(r) { \ (r).tt = MRB_TT_FALSE;\ (r).value.p = 0;\ } //(中略) CASE(OP_LOADNIL) { /* A B R(A) := nil */ int a = GETARG_A(i); SET_NIL_VALUE(regs[a]); NEXT; }
OP_MOVEよりもシンプルだ。
さて、ではこの無駄なOP_LOADNILを消すことを考えてみよう。といっても単純に出力しないようにしてしまうとブロックが無いときに値が不定になってしまうので、OP_LOADNILを出力しない場合でもOP_SENDが自動的にNILで埋めてくれるのが望ましい。ブロックがあるかないかを表現する情報をOP_SENDのオペランドに持たせる必要がある。オペランドの仕様は
CASE(OP_SEND) { /* A B C R(A) := call(R(A),Sym(B),R(A+1),... ,R(A+C-1)) */
ということらしい。命令コード32bitのうち、OPが7bit、Aが9bit、Bが9bit、Cが7bitとなっている。きっちり埋まっていて無駄が無い。
が、よく考えてみると何かがおかしい。レジスタに9bitてR511まで使うということか?Cは引数の数だが7bitってメソッドの引数127個までOKということか?てな具合に。
今回はいかにも多すぎるCのうち上位1bitを使うことにしよう。これが1だと引数の後ろのレジスタにNILが出力されていないので、OP_SENDで出力してから呼び出してもらう。という仕掛けだ。
出力する命令を変えるのでコードジェネレータ(codegen.c)からいじる。
case NODE_OP_ASGN: { mrb_sym sym = (mrb_sym)tree->cdr->car; const char *name = mrb_sym2name(s->mrb, sym); int idx; // : //(中略) // : codegen(s, tree->cdr->cdr->car, VAL); //genop(s, MKOP_A(OP_LOADNIL, cursp())); // ←削除 pop(); pop(); idx = new_msym(s, sym); if (name[0] == '+' && name[1] == '\0') { genop(s, MKOP_ABC(OP_ADD, cursp(), idx, 1 | 0x40)); // ← | 0x40追加 } else if (name[0] == '-' && name[1] == '\0') { genop(s, MKOP_ABC(OP_SUB, cursp(), idx, 1 | 0x40)); // ← | 0x40追加 } // : //(後略) // :
2項演算子はブロックが無い&引数が1つなのが確定しているのでOP_LOADNILの出力を消してフラグを立てる。他にgen_call()もいじった。そっちのがちょっとだけややこしい。
vm(vm.c)のほうは
CASE(OP_SEND) { /* A B C R(A) := call(R(A),Sym(B),R(A+1),... ,R(A+C-1)) */ int a = GETARG_A(i); int n = GETARG_C(i); struct RProc *m; struct RClass *c; mrb_callinfo *ci; mrb_value recv; mrb_sym mid = syms[GETARG_B(i)]; // ↓ここから if (n & 0x40) { n = n & 0x3f; SET_NIL_VALUE(regs[a+n+1]); } // ↑ここまで追加 // : //(後略) // :
というシンプルコードである。使える引数の数は最大63個になってしまうが、そんなに引数を使うことは無いだろう。
今回は手抜きでここしかいじっていないが、OP_TAILCALLとかOP_CALLとか考え出すと影響範囲はでかいし、今後の修正でもひっかかりそうなので微妙ではある。まあ、実験ということで。
p (1+2)をコンパイルするとこうなる。コードジェネレータの出力もちょっとだけいじってある。
irep 148 nregs=5 nlocals=2 pools=0 syms=2 000 OP_LOADSELF R2 001 OP_LOADI R3 1 002 OP_LOADI R4 2 003 OP_ADD R3 :+ 1 blockless 004 OP_SEND R2 :p 1 blockless 005 OP_STOP
上のやつと比較するとばっちりOP_LOADNILが消えている。R5を使っていないように見えるのにnregsが減らないのは内部で出力するぶんを確保しているからだ。これはちょっと紛らわしい。
ベンチ用コード。timesはmrubyコンパイル時にrubyコードがコンパイルされて埋め込まれてしまうので、verboseで見えるように自分で定義しなおし。
class Numeric def times(&block) i = 0 while(i < self) block.call(i) i += 1 end self end end from = Time.now 10000000.times do end to = Time.now p to-from
mruby --verboseするとこうなる(timesの処理)
irep 150 nregs=6 nlocals=4 pools=0 syms=3 000 OP_ENTER 0:0:0:0:0:0:1 001 OP_LOADI R2 0 002 OP_MOVE R4 R2 003 OP_LOADSELF R5 004 OP_LT R4 :< 1 blockless 005 OP_JMPNOT R4 014 006 OP_MOVE R4 R1 007 OP_MOVE R5 R2 008 OP_SEND R4 :call 1 blockless 009 OP_MOVE R4 R2 010 OP_LOADI R5 1 011 OP_ADD R4 :+ 1 blockless 012 OP_MOVE R2 R4 013 OP_JMP 002 014 OP_LOADSELF R4 015 OP_RETURN R4
1ループにつき3個のOP_LOADNILが減った。といってもNILをコピーする処理はOP_SEND内にいるので、実際に減っているのはgotoだけだ。ちなみにOP_SENDしない最適化演算の場合はNILコピーそのものが行われない。
D:\test>mruby test.rb 2.46875 D:\test>mruby_blockless test.rb 2.1875
想像以上に速くなった。まあこれはVM命令しか動かしていないので極端な例ではある。例えばbm_so_listsを動かした場合はArrayのメソッド呼び出しが挟まるから3%ぐらいの改善にとどまる。