Rubyist Magazine - YARV Maniacs 【第 3 回】 命令ディスパッチの高速化を読む (2)
Structured GOTO Programming World へようこそ!
前回はコンパイラさんのやってくれるswitch展開の妙技をobjdumpで眺めました。最適化オプションをつけていったらまたどんどん色々なことしてくわけですけどそれはYARV Maniacsとは逸れていくので割愛。コンパイラにできない最適化、スレッデッドコードをしてやります。
いきなりダイレクトスレッデッドコード化。先にできあがったソースコードを。
#include <stdio.h> #define STACK_SIZE 30000 #ifndef LOOPNUM #define LOOPNUM 100000 #endif typedef enum Instruction Instruction; typedef struct Code Code; enum Instruction { I_PUSH, I_POP, I_DUP, I_ADD, I_SUB, I_MUL, I_DIV, I_NOT, I_JUMP, I_IF, I_EQ, I_LT, I_GT, I_END }; struct Code { union { Instruction type; void *ptr; } inst; int value; }; int eval(Code program[], Code *program_end) { int stack[STACK_SIZE]; int stack_index = 0; int x, y; Code *pc = &program[0]; static void *table[] = { &&I_PUSH, &&I_POP, &&I_DUP, &&I_ADD, &&I_SUB, &&I_MUL, &&I_DIV, &&I_NOT, &&I_JUMP, &&I_IF, &&I_EQ, &&I_LT, &&I_GT, &&I_END }; for(;pc!=program_end;++pc) { pc->inst.ptr = table[pc->inst.type]; } pc = &program[0]; goto *pc->inst.ptr; I_PUSH: stack[stack_index++] = pc->value; ++pc; goto *pc->inst.ptr; I_POP: --stack_index; ++pc; goto *pc->inst.ptr; I_DUP: x = stack[stack_index-1]; stack[stack_index++] = x; ++pc; goto *pc->inst.ptr; I_ADD: x = stack[--stack_index]; stack[stack_index-1] += x; ++pc; goto *pc->inst.ptr; I_SUB: x = stack[--stack_index]; stack[stack_index-1] -= x; ++pc; goto *pc->inst.ptr; I_MUL: x = stack[--stack_index]; stack[stack_index-1] *= x; ++pc; goto *pc->inst.ptr; I_DIV: x = stack[--stack_index]; stack[stack_index-1] /= x; ++pc; goto *pc->inst.ptr; I_NOT: x = stack[stack_index-1]; stack[stack_index-1] = !x; ++pc; goto *pc->inst.ptr; I_JUMP: pc = &program[pc->value]; goto *pc->inst.ptr; I_IF: x = stack[--stack_index]; if(x) { pc = &program[pc->value]; } else { ++pc; } goto *pc->inst.ptr; I_EQ: x = stack[--stack_index]; y = stack[stack_index-1]; stack[stack_index-1] = x==y; ++pc; goto *pc->inst.ptr; I_LT: x = stack[--stack_index]; y = stack[stack_index-1]; stack[stack_index-1] = x<y; ++pc; goto *pc->inst.ptr; I_GT: x = stack[--stack_index]; y = stack[stack_index-1]; stack[stack_index-1] = x>y; ++pc; goto *pc->inst.ptr; I_END: return stack[0]; } int main(int argc, char **argv) { Code program[] = { {I_PUSH, 1}, {I_PUSH, 1}, {I_ADD, 0}, {I_DUP, 0}, {I_PUSH, LOOPNUM}, {I_GT, 0}, {I_IF, 1}, {I_END, 0} }; Code *program_end = &program[sizeof(program)/sizeof(Code)]; int result; result = eval(program, program_end); printf("%d\n", result); return 0; }
いいかげんに解説。
前回書いたVMの「プログラム」の定義は以下のようなものでした。
struct Code { Instruction inst; int value; } *program;
「命令」がstruct Codeで、「プログラム」は「命令」の列。じゃあ今回はダイレクトスレッデッドコードで命令の種類からジャンプ先ポインタに変換するのでこんな定義に。
struct Code { union { Instruction type; void *ptr; } inst; int value; };
変数名が気色悪いけどまあ勘弁してもらって。定義するときの「プログラム」はtypeとvalue、実行するときの「プログラム」はptrとvalue。
プログラムを終了を示す命令I_ENDを追加しました。それに従って与えるプログラムもちょっと変更。
なんか前回解説しなかった気がしますけど、valueを使わない命令(I_PUSH, I_JUMP, I_IF以外全部)においてvalueに入れる値に意味はありません。無駄ですけど気にしないことにしましょう。
「出力されるアセンブラ」のところに書いてるように、pcをintからCode*に変えました。
それにともないevalに渡すのはprogramと、の末端を示すポインタprogram_endに。
eval関数の中へ。
Code *pc = &program[0]; static void *table[] = { &&I_PUSH, &&I_POP, &&I_DUP, &&I_ADD, &&I_SUB, &&I_MUL, &&I_DIV, &&I_NOT, &&I_JUMP, &&I_IF, &&I_EQ, &&I_LT, &&I_GT, &&I_END }; for(;pc!=program_end;++pc) { pc->inst.ptr = table[pc->inst.type]; }
ラベルの値取れて嬉しいですね。programのinstをInstructionからvoid*に変換します。で、そこから先はYARV Maniacsで解説してる通りのコード。
pc = &program[0]; goto *pc->inst.ptr; I_PUSH: stack[stack_index++] = pc->value; ++pc; goto *pc->inst.ptr; I_POP: --stack_index; ++pc; goto *pc->inst.ptr;
で、速くなってるんですか……?
$ gcc -DLOOPNUM=100000000 vmopt.c -o vmopt $ gcc -DLOOPNUM=100000000 vmopt_direct.c -o vmopt_direct $ time ./vmopt 100000000 7.004 user 0.004 system 7.012 total $ time ./vmopt_direct 100000000 4.408 user 0.000 system 4.408 total
速くなってる! じゃあ前回と同様に以下のようにしてコンパイル結果を見る。
$ gcc -DLOOPNUM=100000000 vmopt_direct.c -o vmopt_direct -g $ objdump -S vmopt_direct|lv
ほんでevalのディスパッチ部分を見る。
pc = &program[0]; 80483d9: 8b 45 08 mov 0x8(%ebp),%eax 80483dc: 89 45 fc mov %eax,-0x4(%ebp) goto *pc->inst.ptr; 80483df: 8b 45 fc mov -0x4(%ebp),%eax 80483e2: 8b 00 mov (%eax),%eax 80483e4: 89 85 2c 2b fe ff mov %eax,-0x1d4d4(%ebp) 80483ea: ff a5 2c 2b fe ff jmp *-0x1d4d4(%ebp) I_PUSH: stack[stack_index++] = pc->value; 80483f0: 8b 55 f0 mov -0x10(%ebp),%edx 80483f3: 8b 45 fc mov -0x4(%ebp),%eax 80483f6: 8b 40 04 mov 0x4(%eax),%eax 80483f9: 89 84 95 30 2b fe ff mov %eax,-0x1d4d0(%ebp,%edx,4) 8048400: 83 45 f0 01 addl $0x1,-0x10(%ebp) ++pc; 8048404: 83 45 fc 08 addl $0x8,-0x4(%ebp) goto *pc->inst.ptr; 8048408: 8b 45 fc mov -0x4(%ebp),%eax 804840b: 8b 00 mov (%eax),%eax 804840d: 89 85 2c 2b fe ff mov %eax,-0x1d4d4(%ebp) 8048413: eb d5 jmp 80483ea <eval+0x46> I_POP: --stack_index; 8048415: 83 6d f0 01 subl $0x1,-0x10(%ebp) ++pc; 8048419: 83 45 fc 08 addl $0x8,-0x4(%ebp) goto *pc->inst.ptr; 804841d: 8b 45 fc mov -0x4(%ebp),%eax 8048420: 8b 00 mov (%eax),%eax 8048422: 89 85 2c 2b fe ff mov %eax,-0x1d4d4(%ebp) 8048428: eb c0 jmp 80483ea <eval+0x46>
VM命令の最後、jmp 80483eaとかされてますね。各VM命令最後のgotoも
goto *pc->inst.ptr; 80483df: 8b 45 fc mov -0x4(%ebp),%eax 80483e2: 8b 00 mov (%eax),%eax 80483e4: 89 85 2c 2b fe ff mov %eax,-0x1d4d4(%ebp) 80483ea: ff a5 2c 2b fe ff jmp *-0x1d4d4(%ebp)
としてほしいが為のダイレクトスレッデッドコードだというのに。じゃあ最適化させたらどうなるよと、
$ gcc -DLOOPNUM=100000000 vmopt_direct.c -o vmopt_direct-O1 -O1 -g $ objdump -S vmopt_direct-O1 |lv
goto *pc->inst.ptr; 80483ea: 8b 06 mov (%esi),%eax 80483ec: eb ea jmp 80483d8 <eval+0x34>
なんか色々してるけど肝心のところ変わらない。じゃあもっと最適化
I_PUSH: stack[stack_index++] = pc->value; 80483e8: 8b 46 04 mov 0x4(%esi),%eax ++pc; 80483eb: 83 c6 08 add $0x8,%esi } pc = &program[0]; goto *pc->inst.ptr; I_PUSH: stack[stack_index++] = pc->value; 80483ee: 89 84 9d 34 2b fe ff mov %eax,-0x1d4cc(%ebp,%ebx,4) 80483f5: 83 c3 01 add $0x1,%ebx ++pc; goto *pc->inst.ptr; 80483f8: 8b 06 mov (%esi),%eax for(;pc!=program_end;++pc) { pc->inst.ptr = table[pc->inst.type]; } pc = &program[0]; goto *pc->inst.ptr; 80483fa: ff e0 jmp *%eax
命令の順序とか入れ替えられたりしててすげーわかりにくいなあと思いつつもかろうじてjmp *%eaxは目に入るのでうんダイレクトスレッデッドコードて感じですよね。じゃ-O2でコンパイルしたもの同士比較
$ gcc -DLOOPNUM=100000000 vmopt.c -o vmopt-O2 -O2 $ gcc -DLOOPNUM=100000000 vmopt_direct.c -o vmopt_direct-O2 -O2 $ time ./vmopt-O2 100000000 1.784 user 0.000 system 1.786 total $ time ./vmopt_direct-O2 100000000 1.060 user 0.004 system 1.065 total
他のところもちょいちょい変えてるわけだけども、わー最適化コンパイラができない仕事したぜーと喜べるわけです。