JA EN
体系言語処理系
·★ 会員·13分で読めます

JITとGC — 実行しながら速くする・掃除する

実行時コンパイラ(JIT)とガベージコレクタ(GC)は、どちらもアプリを止めずに裏で働く仕事です。ホットスポット検出・段階コンパイル・インライン化・投機と脱最適化・世代別GC・三色マーキング・ライトバリアを前提知識ゼロから積み上げ、最後はGCログとJITログを自分で読めるところまで。

対象textタスクsystems

開店したまま厨房を組み替える

人気の定食屋を想像してください。開店前に全メニューを均等に仕込むのが事前コンパイル(AOT)です。ところが営業を始めると、注文の八割が三品に集中していると分かります。

JIT(Just-In-Time コンパイラ)は、営業中にその三品だけを専用ラインへ組み替える仕事GC(ガベージコレクタ)は、営業を止めずに皿を下げて席を空け続ける仕事です。教科書では別々の章に置かれますが、悩みは同じです。どちらも店を閉めずにやらなければならない。だから両方とも「いつ・どれだけ手をつけるか」という予算配分の問題になります。

ソースコードに書いていない情報

コンパイラを1からで見たとおり、コンパイラは全体を先に見渡せる立場にいます。それでも手に入らない情報があります。

どれも入力データ次第で決まるので、ソースを何度読んでも書いてありません。実行時コンパイラは走らせながらこれを実測できる代わりに、コンパイルにかかった時間がそのままアプリの実行時間に混ざる重荷を負います。

ホットスポット — 時間はごく一部に偏る

実行時間はメソッドに均等には散りません。数千のメソッドを持つアプリでも、CPU時間の大半はごく少数のループに落ちます。この偏りこそがJITの前提です。あるメソッドをコンパイルすべきかは、一本の不等式で書けます。

nici(11s)>Kn_i \, c_i \left(1 - \tfrac{1}{s}\right) > K
(1)

nin_i は今後そのメソッドが実行される回数、cic_i はインタプリタで1回動かす時間、ss はコンパイル後の速度倍率、KK はコンパイル自体に一度だけかかる費用です。要するに「これから節約できる合計が、いま払うコンパイル代を上回るならやる価値がある」と言っているだけです。

実行時に安く測れるのは nin_i だけです。だからランタイムはメソッドごとに呼び出し回数カウンタを持ち、閾値を超えたものを待ち行列に積みます。HotSpot JVM の名前は、この「熱い場所を探す」やり方から来ています。

FIG 1棒の1本1本をメソッドだと思って、温度スライダーを下げてみてください。数本に尖った状態がホットスポットのあるプログラムで、そこだけコンパイルすれば全体が速くなります。逆に平らにすると、どこを直しても効かない——JITが苦手とする形です

段階を分ける — Tiered Compilation

閾値がひとつだと困ります。低くすればコンパイル待ちで起動が重くなり、高くすればいつまでも遅いままです。答えは段階を刻むことでした。HotSpot はレベル0がインタプリタ、1〜3がC1、4がC2です。C1は速く出す代わりに控えめに最適化し、C2は時間をかけて攻めます。C1コードにはプロファイル計測が仕込んであるので、C2の番が回るころには「この分岐は99%こっち」という統計が溜まっています。V8 も Ignition→Sparkplug→Maglev→TurboFan と段を積んでいます。

閾値だけでは取りこぼす形もあります。一度しか呼ばれない巨大なループです。呼び出し回数は1のまま動きません。そこでループの後ろ向き分岐にもカウンタを置き、回っている最中にフレームごとコンパイル済みコードへ乗り換えます。これが On-Stack Replacement(OSR)です。

インライン化が、すべての最適化の親

インライン化は「呼び出し先の中身を呼び出し元に埋め込む」変換です。数ナノ秒の呼び出し費用を節約するためだと思われがちですが、本体は埋め込んだ後に何ができるようになるかのほうです。定数の畳み込み、通らない分岐の削除、ループ不変式の巻き上げ、後述するエスケープ解析が、関数の境界をまたいで効くようになります。

Javaのメソッドは既定で仮想呼び出しなので、そのままでは呼び先が決まりません。ここで実行時プロファイルが効きます。「この地点に来たのは全部 ArrayList だった」と分かっていれば、クラスチェック1回のガードを置いて一つに賭け、インライン化できます。見えた型が1種類なら単形(monomorphic)、3種類以上になると多形(megamorphic)として諦め、普通の仮想呼び出しに戻ります。

ただし無制限には展開できません。深さddまで各段bb個を展開すると、コード量はおおよそbdb^dで膨らみます。

FIG 2横軸を「インライン化を許す深さ」と読み替えてください。上の曲線に乗った瞬間、コードは命令キャッシュに収まらなくなり、速くするための最適化が遅さの原因に変わります

だからJITには -XX:MaxInlineSize(小さいメソッドを無条件に展開する上限)や -XX:FreqInlineSize(熱いメソッドに許す上限)という予算のつまみがあります。キャッシュに収まるかどうかで何倍も差がつく話はキャッシュに優しいコードにあります。

JITは「たぶんこうだろう」でコードを出します。この引数に `null` が来たことはないので検査を消す。この `catch` に入ったことはないので例外処理を落とす。まだ一つも実装クラスが読み込まれていないので、仮想呼び出しを直接呼び出しに書き換える。

この先にあるもの

§

ここから先は会員限定です

解説記事371本・教科書26章・学生モード48単元・論文精読6本が、月額¥490ですべて読み放題になります。新しい解説は毎日3本ずつ増えます。いつでも解約でき、解約後も期間の終わりまで読めます。

会員の方はログインすると続きが表示されます

コメント

コメントにはログインが必要です