Rubyist Magazine - YARV Maniacs 【第 3 回】 命令ディスパッチの高速化を読む (1)

http://d.hatena.ne.jp/hogelog/20080704/p1 で書いたVMを高速化。というかコンパイラさんの頑張りを見てみよう。
まず前回書いたvm.cをちょっとだけ修正。

@@ -1,6 +1,9 @@
 #include <stdio.h>

 #define STACK_SIZE 30000
+#ifndef LOOPNUM
+#define LOOPNUM 100000
+#endif

 typedef enum Instruction Instruction;
 typedef struct Code Code;
@@ -90,7 +93,7 @@
     {I_PUSH, 1},
     {I_ADD, 0},
     {I_DUP, 0},
-    {I_PUSH, 100000},
+    {I_PUSH, LOOPNUM},
     {I_GT, 0},
     {I_IF, 1}
   };

でまあ適当にコンパイルして実行

$ gcc -DLOOPNUM=100000000 vm.c -o vm
$ time ./vm
100000000
8.061 user 0.004 system 8.062 total

100000000回中身の無いループするプログラム実行、こんなものと。るびま第3回見ると、とりあえず++pcはjump, if命令との兼ね合いからして中入れちゃった方が良さげ。というわけでforの中身を書き換える。

  for(pc=0;pc<program_len;) {
    Code code = program[pc];
    switch(code.inst) {
      case I_PUSH:
        stack[stack_index++] = code.value;
        ++pc;
        break;
      case I_POP:
        --stack_index;
        ++pc;
        break;
      case I_DUP:
        x = stack[stack_index-1];
        stack[stack_index++] = x;
        ++pc;
        break;
      case I_ADD:
        x = stack[--stack_index];
        stack[stack_index-1] += x;
        ++pc;
        break;
      case I_SUB:
        x = stack[--stack_index];
        stack[stack_index-1] -= x;
        ++pc;
        break;
      case I_MUL:
        x = stack[--stack_index];
        stack[stack_index-1] *= x;
        ++pc;
        break;
      case I_DIV:
        x = stack[--stack_index];
        stack[stack_index-1] /= x;
        ++pc;
        break;
      case I_NOT:
        x = stack[stack_index-1];
        stack[stack_index-1] = !x;
        ++pc;
        break;
      case I_JUMP:
        pc = code.value;
        break;
      case I_IF:
        x = stack[--stack_index];
        if(x) {
          pc = code.value;
        } else {
          ++pc;
        }
        break;
      case I_EQ:
        x = stack[--stack_index];
        y = stack[stack_index-1];
        stack[stack_index-1] = x==y;
        ++pc;
        break;
      case I_LT:
        x = stack[--stack_index];
        y = stack[stack_index-1];
        stack[stack_index-1] = x<y;
        ++pc;
        break;
      case I_GT:
        x = stack[--stack_index];
        y = stack[stack_index-1];
        stack[stack_index-1] = x>y;
        ++pc;
        break;
      default:
        ++pc;
        break;
    }
  }

で実行。

$ gcc -DLOOPNUM=100000000 vmopt.c -o vmopt
$ time ./vmopt
100000000
7.756 user 0.000 system 7.769 total

まあきっと速くなってますね。if命令のときに余計なcode.value-1と++pcしなくて済んでるからか。で、これって本当にswitchを展開できてるのかな*1。objdump -S vmopt|lvなどしてeval部分を眺める

080483a4 <eval>:
 80483a4:       55                      push   %ebp
 80483a5:       89 e5                   mov    %esp,%ebp
 80483a7:       81 ec ec d4 01 00       sub    $0x1d4ec,%esp
 80483ad:       c7 45 f0 00 00 00 00    movl   $0x0,-0x10(%ebp)
 80483b4:       c7 45 f4 00 00 00 00    movl   $0x0,-0xc(%ebp)
 80483bb:       e9 b7 02 00 00          jmp    8048677 <eval+0x2d3>
 80483c0:       8b 45 f4                mov    -0xc(%ebp),%eax
 80483c3:       c1 e0 03                shl    $0x3,%eax
 80483c6:       03 45 08                add    0x8(%ebp),%eax
 80483c9:       8b 50 04                mov    0x4(%eax),%edx
 80483cc:       8b 00                   mov    (%eax),%eax
 80483ce:       89 85 28 2b fe ff       mov    %eax,-0x1d4d8(%ebp)
 80483d4:       89 95 2c 2b fe ff       mov    %edx,-0x1d4d4(%ebp)
 80483da:       8b 85 28 2b fe ff       mov    -0x1d4d8(%ebp),%eax
 80483e0:       89 85 1c 2b fe ff       mov    %eax,-0x1d4e4(%ebp)
 80483e6:       83 bd 1c 2b fe ff 0c    cmpl   $0xc,-0x1d4e4(%ebp)
 80483ed:       0f 87 80 02 00 00       ja     8048673 <eval+0x2cf>
 80483f3:       8b 95 1c 2b fe ff       mov    -0x1d4e4(%ebp),%edx
 80483f9:       8b 04 95 00 88 04 08    mov    0x8048800(,%edx,4),%eax
 8048400:       ff e0                   jmp    *%eax

x86わかんねー! というわけでcと合わせて読もう*2gccデバッグオプション-gオプション追加。

$ gcc -DLOOPNUM=100000000 vmopt.c -o vmopt -g
$ objdump -S vmopt |lv

でevalを探すと

int eval(Code program[], int program_len) {
 80483a4:	55                   	push   %ebp
 80483a5:	89 e5                	mov    %esp,%ebp
 80483a7:	81 ec ec d4 01 00    	sub    $0x1d4ec,%esp
  int stack[STACK_SIZE];
  int stack_index = 0;
 80483ad:	c7 45 f0 00 00 00 00 	movl   $0x0,-0x10(%ebp)
  int pc;
  int x, y;

こんな感じでcのコードと一緒に書いてあるので嬉しい。それで肝心のループ内部を見ると

  for(pc=0;pc<program_len;) {
 80483b4:	c7 45 f4 00 00 00 00 	movl   $0x0,-0xc(%ebp) # pc(= -0xc(%ebp))に0入れる
 80483bb:	e9 b7 02 00 00       	jmp    8048677 <eval+0x2d3>

pcに0入れて8048677に飛べと。

  for(pc=0;pc<program_len;) {
 8048677:	8b 45 f4             	mov    -0xc(%ebp),%eax # %eaxにpc入れて
 804867a:	3b 45 0c             	cmp    0xc(%ebp),%eax # pcとprogram_len(= 0xc(%ebp))比較
 804867d:	0f 8c 3d fd ff ff    	jl     80483c0 <eval+0x1c> # pc<lenだったらジャンプ

pc<program_lenなら80483c0にジャンプ。80483c0から8048677はディスパッチ部分。

    Code code = program[pc];
 80483c0:	8b 45 f4             	mov    -0xc(%ebp),%eax # eax = pc
 80483c3:	c1 e0 03             	shl    $0x3,%eax
 80483c6:	03 45 08             	add    0x8(%ebp),%eax # eax = program[pc]
 80483c9:	8b 50 04             	mov    0x4(%eax),%edx # edx = program[pc].value
 80483cc:	8b 00                	mov    (%eax),%eax # eax = program[pc].inst
 80483ce:	89 85 28 2b fe ff    	mov    %eax,-0x1d4d8(%ebp) # code.inst = program[pc].inst
 80483d4:	89 95 2c 2b fe ff    	mov    %edx,-0x1d4d4(%ebp) # code.value = program[pc].value

と、今回実行する命令をcodeに入れてswitch

    switch(code.inst) {
 80483da:	8b 85 28 2b fe ff    	mov    -0x1d4d8(%ebp),%eax # eax = code.inst
 80483e0:	89 85 1c 2b fe ff    	mov    %eax,-0x1d4e4(%ebp)
 80483e6:	83 bd 1c 2b fe ff 0c 	cmpl   $0xc,-0x1d4e4(%ebp) # 12(ラベル数13 - 1) とcode.inst比較 
 80483ed:	0f 87 80 02 00 00    	ja     8048673 <eval+0x2cf> # code.inst>12ならジャンプ

8048673ってのはdefault:のことです。

      default:
        ++pc;
 8048673:       83 45 f4 01             addl   $0x1,-0xc(%ebp)

そうじゃなくて定めた命令だったら

 80483f3:	8b 95 1c 2b fe ff    	mov    -0x1d4e4(%ebp),%edx # edx = code.inst
 80483f9:	8b 04 95 00 88 04 08 	mov    0x8048800(,%edx,4),%eax
 8048400:	ff e0                	jmp    *%eax

えーとなんかこう、{8048402(= case I_PUSH:), 804841f(= case I_POP:), ...}といったジャンプ先を保持したテーブル0x8048800(,%edx,4)から引いて飛んでるのだと思う。0x8048800(,%edx,4)の読み方がわからん後で調べる。cmpとjeの連続とかになってなくてjmp一発で嬉しいですね! それ以降、各種命令つらつらと

      case I_PUSH:
        stack[stack_index++] = code.value;
 8048402:	8b 55 f0             	mov    -0x10(%ebp),%edx
 8048405:	8b 85 2c 2b fe ff    	mov    -0x1d4d4(%ebp),%eax
 804840b:	89 84 95 30 2b fe ff 	mov    %eax,-0x1d4d0(%ebp,%edx,4)
 8048412:	83 45 f0 01          	addl   $0x1,-0x10(%ebp)
        ++pc;
 8048416:	83 45 f4 01          	addl   $0x1,-0xc(%ebp)
 804841a:	e9 58 02 00 00       	jmp    8048677 <eval+0x2d3>
        break;
      case I_POP:
        --stack_index;
 804841f:	83 6d f0 01          	subl   $0x1,-0x10(%ebp)
        ++pc;
 8048423:	83 45 f4 01          	addl   $0x1,-0xc(%ebp)
 8048427:	e9 4b 02 00 00       	jmp    8048677 <eval+0x2d3>
...

それぞれのVM命令処理を終えたら8048677にジャンプ。8048677は上にも書いた、ループ終了判定部分。

  for(pc=0;pc<program_len;) {
 8048677:	8b 45 f4             	mov    -0xc(%ebp),%eax
 804867a:	3b 45 0c             	cmp    0xc(%ebp),%eax
 804867d:	0f 8c 3d fd ff ff    	jl     80483c0 <eval+0x1c>
      default:
        ++pc;
        break;
    }
  }

pc<program_lenになったら以下のコードを実行、evalおしまい。

  return stack[0];
 8048683:	8b 85 30 2b fe ff    	mov    -0x1d4d0(%ebp),%eax
}
 8048689:	c9                   	leave  
 804868a:	c3                   	ret    

と。

gcc先生すごいですね、switchの展開してくれてよかった。objdumpはx86学ぶのに良さそうですね。えーとここまででRubyist Magazine - YARV Maniacs 【第 3 回】 命令ディスパッチの高速化の「高速化を考えてみる」までですかね。眠いので一旦ここまで。

test