コードジェネレータとwhileアルゴリズム
最近mrubyをいじって遊んでいるわけだが、mrubyを気合入れて速くするぜ貢献するぜ、という感じではなくて、どのように変更を加えたらどのぐらい速度が変わるのか、を色々試している感じ。だからとても取り込まれそうにない修正もするし、すぐにでも取り込めそうな修正もする(かもしれない)。まあ、結果として有効な手法が見えてこれば、その方向にパワーをかけるというのはありかもしれない。
今回はwhileを書いたときのコード生成に関して。
昨日こんなコードをためした。
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
このコードが出力する命令列はこのような感じだった(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_LOADNIL R6 005 OP_LT R4 :< 1 006 OP_JMPNOT R4 017 007 OP_MOVE R4 R1 008 OP_MOVE R5 R2 009 OP_LOADNIL R6 010 OP_SEND R4 :call 1 011 OP_MOVE R4 R2 012 OP_LOADI R5 1 013 OP_LOADNIL R6 014 OP_ADD R4 :+ 1 015 OP_MOVE R2 R4 016 OP_JMP 002 017 OP_LOADSELF R4 018 OP_RETURN R4
この命令をじっと眺めていると、このようなアルゴリズムになっているのが見えてくる。
i = 0 :label_a unless i < 1000000 goto :label_b endif i = i + 1 goto label_a :label_b return self
まあ、見たままwhileの動きだ。が、whileはこのように書くほうが速い。
i = 0 goto :label_b :label_a i = i + 1 :label_b if i < 1000000 goto :label_a endif return self
なぜかというと、上のコードはunlessの際に偽だったら最後に飛ぶためのコードが出力されるからだ。下のコードは真だったら戻るジャンプになってループ用のジャンプと共有できる。goto1個ぶん違う。微々たるものだが。
せっかくなのでコードジェネレータを修正してみよう。codegen.cの該当部分はここ。
case NODE_WHILE: { struct loopinfo *lp = loop_push(s, LOOP_NORMAL); lp->pc1 = new_label(s); codegen(s, tree->car, VAL); pop(); lp->pc2 = new_label(s); genop(s, MKOP_AsBx(OP_JMPNOT, cursp(), 0)); codegen(s, tree->cdr, NOVAL); genop(s, MKOP_sBx(OP_JMP, lp->pc1 - s->pc)); dispatch(s, lp->pc2); loop_pop(s, val); } break;
ここで使われているsは命令列の現在の出力先をあらわす構造体へのポインタ。lpのpc1やpc2はラベルとしてアドレスを保存できる。codegen()はこのコードが書かれている関数自身で、現在の位置にtreeの命令列を出力する。genopは単体の命令の生成だ。
動きとしては以下のような感じに読める。
・はじめの位置をpc1に保存する。
・tree->carには判定の構文木が入っているので、まずそれを生成する。
・OP_JMPNOTの位置をpc2に保存する。
・↑↑の判定命令の結果を判定して分岐するためのOP_JMPNOTを生成する。
ここで最後の引数の0は本来はジャンプ先への相対位置が入るのだが、この時点ではそれが不明なので0にしてある。直前のnew_label()でpc2にこの命令の位置を保存して、後ろのほうで飛び先アドレスが確定した時点でdispatch()を呼んでこの0のところに相対アドレスを書き込む。この飛び先はwhileを抜けた次の命令となる。
・tree->cdrはwhileの中身なのでそれを生成する。
・pc1から現在の位置を引いたぶん(pc1に戻るための相対アドレス)ジャンプするOP_JMPを生成する。
・dispatch()でOP_JMPNOTを完成させる。
このコードを次のようになおす。
case NODE_WHILE: { struct loopinfo *lp = loop_push(s, LOOP_NORMAL); lp->pc1 = new_label(s); genop(s, MKOP_sBx(OP_JMP, 0)); lp->pc2 = new_label(s); codegen(s, tree->cdr, NOVAL); dispatch(s, lp->pc1); codegen(s, tree->car, VAL); pop(); genop(s, MKOP_AsBx(OP_JMPIF, cursp(), lp->pc2 - s->pc)); loop_pop(s, val); } break;
しょっぱなJMPするのでpc1にアドレス保存と飛び先0でJMPを生成。ループ時の飛び先はJMP直後になるのでそこでpc2を保存。tree->cdr(whileの中身)の命令を生成してからアドレスをpc1にdispatch()で書き込み、tree->car(判定式)を生成してOP_JMPIFでpc2に戻る。
この修正でVM命令列はこのようになる。
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_JMP 012 003 OP_MOVE R4 R1 004 OP_MOVE R5 R2 005 OP_LOADNIL R6 006 OP_SEND R4 :call 1 007 OP_MOVE R4 R2 008 OP_LOADI R5 1 009 OP_LOADNIL R6 010 OP_ADD R4 :+ 1 011 OP_MOVE R2 R4 012 OP_MOVE R4 R2 013 OP_LOADSELF R5 014 OP_LOADNIL R6 015 OP_LT R4 :< 1 016 OP_JMPIF R4 003 017 OP_LOADSELF R4 018 OP_RETURN R4
全体の命令数は変わらないが、ループの中の命令は1個減った。011と012のOP_MOVEが無駄なことをしているように見えるが、002のOP_JMPから飛び込むのが012なのでこれは必要な処理だ。
この2つを動かして比較してみる。
D:\test>mruby test.rb 2.515625 D:\test>mruby_test test.rb 2.453125
元のほうの数字が昨日と若干違うのは誤差なのだが、誤差範囲にとどまりそうな勢いであまり変わらない。
ループ内の命令が減ったのは事実なので速くなったのはなったんじゃないかなと思う。