mrubyのContextThreading化 その2

前回に引き続きmrubyをContextThreading化する。基本命令が動作するところまではできたので、次はOP_JMP系だ。
そのまえに、ローカル変数が動かなかった件は解決した。単純にi=*pcを書き忘れていて初期化されていなかっただけだった。よくそれで動いていたものだ。

さて、OP_JMPが動作しないのは、mrubyのVMがOP_JMPの際にpcしかいじらないからである。pcは通常のmrubyであれば実行する命令を表すから、それさえ操作すれば実行する命令を変えることができる。でも、ContextThreadingの場合は実行する命令を決めるのはpcではなく、生成したマシン語のコードの方なので、pcだけ変えてもジャンプしてくれない。生成コードとpcにズレが発生すると意味不明な動作をし始める。
OP_JMPを実装するためには、マシン語のコード側でネイティブにジャンプしてやる必要がある。それをするためにはjmp命令の生成が必要で、jmp命令を生成するためには飛び先のアドレスが必要だ。従って、命令を生成するときにそれらのアドレスを保持して、とりあえず飛び先は空白にして、後から2パス目でセットしてやらねばならない。

OP_JMPIF/OP_JMPNOTはもう一段ややこしい。なぜならVM命令側で値を判定してpcを動かしても、生成コードのほうはその結果がわからないからだ。結果がわからなければ条件ジャンプのしようが無い。実装の方法として、生成するコードの中でVMスタックを参照して値を取り、true/falseを判定してやる手があるが、これは大変面倒なことになる。最もラクなのはOP_JMPIF/OP_JMPNOT内で判定命令をインラインアセンブラで動かして、結果をフラグで返してやる方法だろう。

このように生成コード内にネイティブ分岐命令を埋め込んでフローを制御する方法をbranch-inliningと言う。
今回はそれを実装してみる。


上記の動きを実装した生成コードはこのようになる。後々のことを考えてマクロ化しておいた。命令長が違う(jmpは5byte、je/jneは6byte)ので、命令ごとにアドレスを足していく処理が必要になる。adr_bufは命令ごとの先頭アドレスを入れる領域で、最後にfreeする。命令を格納する領域であるdataは必要サイズがわからないので、最も大きいサイズに合わせて取ってしまっている。最後にreallocしてやったほうがいいかもしれない。

#define MAKE_CT_FUNC \
  if (!irep->ct_func) {\
    int index;\
    unsigned char **adr_buf = mrb_malloc(mrb, irep->ilen * sizeof(int));\
    unsigned char *data = mrb_malloc(mrb, irep->ilen * 11 + 3);\
    unsigned char *adr = data;\
    for (index = 0; index < irep->ilen; index++) {\
      adr_buf[index] = adr;\
      *adr = 0xe8;\
      *(int**)(adr + 1) = (int*)((unsigned char*)optable[GET_OPCODE(irep->iseq[index])] - (adr + 5));\
      adr += 5;\
      switch GET_OPCODE(irep->iseq[index]) {\
      case OP_JMP:\
        *adr = 0xe9;\
        *(int**)(adr + 1) = 0x00000000;\
        adr += 5;\
        break;\
      case OP_JMPIF:\
        *adr = 0x0f;\
        *(adr + 1) = 0x85;\
        *(int**)(adr + 2) = 0x00000000;\
        adr += 6;\
        break;\
      case OP_JMPNOT:\
        *adr = 0x0f;\
        *(adr + 1) = 0x86;\
        *(int**)(adr + 2) = 0x00000000;\
        adr += 6;\
        break;\
      }\
    }\
    *adr = 0xc2;\
    *(adr + 1) = 0x00;\
    *(adr + 2) = 0x00;\
    for (index = 0; index < irep->ilen; index++) {\
      switch GET_OPCODE(irep->iseq[index]) {\
      case OP_JMP:\
        *(int**)(adr_buf[index] + 6) = (int*)(adr_buf[index + GETARG_sBx(irep->iseq[index])] - (adr_buf[index] + 10));\
        break;\
      case OP_JMPIF:\
      case OP_JMPNOT:\
        *(int**)(adr_buf[index] + 7) = (int*)(adr_buf[index + GETARG_sBx(irep->iseq[index])] - (adr_buf[index] + 11));\
        break;\
      }\
    }\
    irep->ct_func = (void*)data;\
    mrb_free(mrb, adr_buf);\
  }

ポイントは、OP_JMP系の命令の場合はほんとにジャンプしかしないようにして、VMの命令をCALLしてpcの操作をそっちでやってもらうところ。んで、OP_JMPIF/OP_JMPNOTの場合はje/jne命令でジャンプする。VMの命令側でフラグをセットしてもらえればこっちはこれだけできちんと動く。
VM命令側はこんな感じ。

    CASE(OP_JMP) {
      /* sBx    pc+=sBx */
      pc += GETARG_sBx(i);
      JUMP;
    }

    CASE(OP_JMPIF) {
      /* A sBx  if R(A) pc+=sBx */
      if (mrb_test(regs[GETARG_A(i)])) {
        pc += GETARG_sBx(i);
        i=*pc;
      } 
      else {
        i=*++pc;
      }
      asm("addl $40, %esp;");
      asm("cmpb $0, %0" : : "r" (regs[GETARG_A(i)].tt));
      asm("ret $0");
      NEXT;
    }

    CASE(OP_JMPNOT) {
      /* A sBx  if R(A) pc+=sBx */
      if (!mrb_test(regs[GETARG_A(i)])) {
        pc += GETARG_sBx(i);
        i=*pc;
      } 
      else {
        i=*++pc;
      }
      asm("addl $40, %esp;");
      asm("cmpb $0, %0" : : "r" (regs[GETARG_A(i)].tt));
      asm("ret $0");
      NEXT;
    }

NEXT/JUMPルーチンを展開して自前で書いてある。スタック戻しの後、retの前に比較命令を追加した。これはttが0の場合はfalse/nilを表すため、0との比較となっている。これで生成コードに戻ったときにゼロフラグが1になっていればfalseで、OP_JMPIFではjneを生成すればtrueの時に飛んでくれる。OP_JMPNOTはjeを使う。
あと直したところといえば、比較演算子などでFixnum/Float以外の場合にOP_SENDにgotoするようになっていて、そのせいでマシンスタックがずれていたからL_SENDラベルの位置をちょっと後ろにずらした。

//  L_SEND:
    CASE(OP_SEND)
  L_SEND:

これでwhileを動かすことができて、一応のベンチマークは取れる。いまのところ-O3で最適化するとコケるので-O0でコンパイルしている。同様に-O0でコンパイルした元のmrubyと比較してみよう。

i = 0
from = Time.now
while i < 10000000
  i = i + 1
end
to = Time.now
__printstr__ (to-from).to_s
D:\test>mruby test.rb
1.625
D:\test>mruby_test test.rb
1.421875

スタックいじったりジャンプのときに比較したりと余計な命令を出力しているくせにずいぶん速い。これが間接分岐を駆逐した効果ということで、やっぱり間接分岐は遅いということだ。

ところで、いまの状態ではRubyのブロックが呼べない。mrubyのVMではブロックの呼び出しはマシンスタックを使わず、CallInfoを積んでirepを切り替え、そこにpcを移動させる形で実装されている。irepを切り替えるということは、生成コードも切り替わるということで、呼び出し時に次のirepのiseqのコードを生成してやる必要がある。
このへんはダイレクトスレッデッドコードと似たような感じに対策できる。

    CASE(OP_SEND)
  L_SEND:
    {
//(略)
      else {
        /* setup environment for calling method */
        proc = mrb->ci->proc = m;
        irep = m->body.irep;
        pool = irep->pool;
        syms = irep->syms;
        ci->nregs = irep->nregs;
        if (ci->argc < 0) {
          stack_extend(mrb, (irep->nregs < 3) ? 3 : irep->nregs, 3);
        }
        else {
          stack_extend(mrb, irep->nregs,  ci->argc+2);
        }
        regs = mrb->stack;
        pc = irep->iseq;

//こっから
        MAKE_CT_FUNC
        i = *pc;
        irep->ct_func();
//ここまで追加

        JUMP;
      }
    }

しかし現在これが動いていない。調べた限りではGCCが想定外のスタック操作をしていて、そのせいでretのアドレスがズレているように見えるのだが、どうも複数の現象が混在しているようでよくわからない。
解決できればその3を書くが、解決できないようならContextThreadingネタは終わりになる。
まあ、ここまで動いてベンチ取れたからちょっと満足、みたいな。反省はしていない。