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%ぐらいの改善にとどまる。