KDOC 264: 『YARV Maniacs』
この文書のステータス
- 作成
- 2024-11-17 貴島
- レビュー
- 2024-11-19 貴島
概略
YARV ManiacsはRubyのVM、YARVの作者が書いた記事シリーズ。
メモ
- もっとも単純なディスパッチ処理はswitch/caseで分岐させること。つぎに関数ポインタを使った例がある。命令配列に関数ポインタを入れておいて命令を取り出す
#define Ia 0
//...
static const InsnFuncType InsnInstruction_bodies[] = {
Ia_body, Ib_body, ..., Iz_body,
};
while(1){
Instruction_bodies[iseq[pc]]();
}
- switch/caseによる分岐は実際の機械語としてはif文の並びのようになる。たくさん分岐することになる。そこで、飛び先をテーブルにして、関数ポインタによる命令ディスパッチの例のように飛び先をテーブルに格納してジャンプできるようにする
loop_start:
goto *iseq_ptr[PC];
Ia:
Ia_body();
goto *iseq_ptr[PC];
Ib:
Ib_body();
goto *iseq_ptr[PC];
...
Iz:
Iz_body();
goto *iseq_ptr[PC];
- こういう命令ディスパッチをするプログラムをスレッデッドコードという
&&ラベル名でラベルを値として扱う。その値としてのラベルにジャンプするにはgoto *valueと記述する- スレッデッドコードの利点はgotoが1個減っただけ。1命令減っただけに見えるが、さらにうれしいことがある。プロセッサの分岐予測の成功率が高くなる。switch/caseの場合は分岐予測が毎回外れるため遅い
- JITコンパイルは、VM命令の命令ディスパッチを必要としなくなる。でも実装が大変である、という
- YARVでは Rubyプログラム -> YARV命令列 -> C と変換するコンパイラを用意している。そのため命令ディスパッチ自体のオーバーヘッドは無い
- switch/case を利用した命令ディスパッチとスレッデッドコードを利用したプログラムはあまり見かけは変わらないのでマクロで書いておけば切り替えは簡単にできる。そうしているVMや処理系は多い、という
0003 send :puts, 1, false, 0, <ic>
- puts: putsメソッドの起動を表す
- 1: 引数を1つで起動
- send命令は、引数の数分だけの値と、レシーバをスタックから取ってメソッドを起動する、という意味になる
puts RubyVM::InstructionSequence.compile('true;false;123;:sym;/abc/;nil').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,29)> (catch: FALSE)
0000 putnil ( 1)[Li]
0001 leave
puts RubyVM::InstructionSequence.compile('a = "abc"').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,9)> (catch: FALSE)
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] a@0
0000 putstring "abc" ( 1)[Li]
0002 dup
0003 setlocal_WC_0 a@0
0005 leave
- 文字列オブジェクトは破壊的な変更が可能なのでmuutableといえる。たとえばプログラム上同じ位置にあっても文字列リテラルが返すオブジェクトは毎回異なる
- putobjectは毎回同じオブジェクトをスタックトップに置く。なので文字列ではputobject命令は使えない。代わりにputstringを使う
puts RubyVM::InstructionSequence.compile('["a", "b", "c"]').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,15)> (catch: FALSE)
0000 putstring "a" ( 1)[Li]
0002 putstring "b"
0004 putstring "c"
0006 newarray 3
0008 leave
puts RubyVM::InstructionSequence.compile('[1, 2, 3]').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,9)> (catch: FALSE)
0000 duparray [1, 2, 3] ( 1)[Li]
0002 leave
- 要素がすべてリテラル(putobject命令で済むもの)だった場合、毎回各要素をスタックに積まなくても作るものが決まっている。duparray命令1つ、配列オブジェクトをdupしてスタックトップに積む、でよくなる
puts RubyVM::InstructionSequence.compile('@a = 1; a = @a').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,14)> (catch: FALSE)
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] a@0
0000 putobject_INT2FIX_1_ ( 1)[Li]
0001 setinstancevariable :@a, <is:0>
0004 getinstancevariable :@a, <is:0>
0007 dup
0008 setlocal_WC_0 a@0
0010 leave
puts RubyVM::InstructionSequence.compile('$global = 1; a = $global').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,24)> (catch: FALSE)
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] a@0
0000 putobject_INT2FIX_1_ ( 1)[Li]
0001 setglobal :$global
0003 getglobal :$global
0005 dup
0006 setlocal_WC_0 a@0
0008 leave
puts RubyVM::InstructionSequence.compile('C').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,1)> (catch: FALSE)
0000 opt_getinlinecache 9, <is:0> ( 1)[Li]
0003 putobject true
0005 getconstant :C
0007 opt_setinlinecache <is:0>
0009 leave
puts RubyVM::InstructionSequence.compile('::C').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,3)> (catch: FALSE)
0000 opt_getinlinecache 12, <is:0> ( 1)[Li]
0003 pop
0004 putobject Object
0006 putobject true
0008 getconstant :C
0010 opt_setinlinecache <is:0>
0012 leave
puts RubyVM::InstructionSequence.compile('C::D').disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,4)> (catch: FALSE)
0000 opt_getinlinecache 13, <is:0> ( 1)[Li]
0003 putobject true
0005 getconstant :C
0007 putobject false
0009 getconstant :D
0011 opt_setinlinecache <is:0>
0013 leave
- 定数の検索は、処理系実装者から見るとかなり作るのが面倒な部分になっている。コンパイル時に定数検索パスが定まらないため
- メソッド定義の探索が計算負荷が大きい。そのためキャッシュを使う。グローバルメソッドキャッシュやインラインメソッドキャッシュ
0000 putnil # スタックトップにnilを積む
0001 dup # スタックトップを複製する (c = nil用)
0002 setlocal c # スタックトップのnilをcに代入する
0004 dup # スタックトップを複製する (c = b用)
0005 setlocal b # スタックトップのnilをbに代入する
0007 setlocal a # スタックトップのnilをaに代入する
0009 getlocal a # aをスタックに積む
0011 dup # スタックトップを複製する
0012 unless 23 # スタックトップが偽なら23行目に飛ぶ(終了)
0014 pop # 片付ける
0015 getlocal b # bをスタックに積む
0017 dup # スタックトップを複製する
0018 unless 23 # スタックトップが偽なら23行目に飛ぶ(終了)
0020 pop # 片付ける
0021 getlocal c # cはスタックに積むだけ
0023 end
- InstructionSequence(iseq) は、命令列
str = <<"EOF"
class C
def m
end
end
EOF
puts RubyVM::InstructionSequence.compile(str).disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(4,5)> (catch: FALSE)
0000 putspecialobject 3 ( 1)[Li]
0002 putnil
0003 defineclass :C, <class:C>, 0
0007 leave
== disasm: #<ISeq:<class:C>@<compiled>:1 (1,2)-(4,5)> (catch: FALSE)
0000 definemethod :m, m ( 2)[LiCl]
0003 putobject :m
0005 leave ( 4)[En]
== disasm: #<ISeq:m@<compiled>:2 (2,4)-(3,7)> (catch: FALSE)
0000 putnil ( 2)[Ca]
0001 leave ( 3)[Re]
- 算術メソッドは高速化した特化命令にコンパイルされる。
opt_plusなど- レシーバと引数がFixnumのとき、のように型の条件がある
opt_plusで実行するとメソッド呼び出し処理(メソッドフレームの準備、破棄)が起きず、早くなる- チェックのオーバーヘッドがあるので、なんでもやればいいわけではない。よくある組み合わせに対して用意する
- C言語などでは1+2のような式はコンパイル時に3に変換する。この最適化を定数畳み込みという。YARVでは定数畳み込みをしていない。メソッドの再定義に対応するため。再定義はいつ起こるかコンパイル時にはわからない
- いわゆるJITコンパイルというと、機械語まで変換する、という意味で使うことが多い。Java仮想マシンの高速化技術という文脈で広がった、という
用語
- メソッドディスパッチ
- オブジェクト指向でいうメソッドを実際に起動する機能。Rubyだとレシーバオブジェクトがあって、メソッド名があったとき、実際にメソッドを起動するときに処理をさせること
- 命令ディスパッチ
- 命令によってそれに応じて処理をさせること
関連
- 対比: KDOC 151: Goコンパイラの定数式の埋め込みをみる。前もって答えが決まる計算を事前に済ませる最適化を、再定義がありうるため行わない点で逆になる
- 類推: KDOC 190: 『Rubyのしくみ Ruby Under a Microscope』。同様にRubyの内部的な仕組みを解説する