March 2016

Volume 31 Number 3

コンパイラ - バックグラウンド JIT を使用したマネージ コードのガイド付き最適化のプロファイル

Hadi Brais | March 2016

コンパイラが行うパフォーマンスの最適化の中には、必ず効果を発揮するものもあります。つまり、その最適化を行えば、実行時にコードのどの部分が実行されても、パフォーマンスが向上します。たとえば、ベクター化を可能にするループ アンローリングを考えてみます。この最適化は、ループの形態を変え、ループの本文内で 1 セットのオペランドに対して 1 回の演算 (異なる配列に格納された 2 つの整数の加算など) を行う場合、これを同時に複数セットのオペランドに対して同じ演算 (4 組の整数の加算) を実行するようにします。

これに対して、非常に重要な最適化で、コンパイラがヒューリスティックに実行するものもあります。この場合、コンパイラには、実行時にコードのパフォーマンスを実際に向上できる最適化方法が正確には分かりません。このカテゴリに分類される最適化のうち (おそらく全カテゴリの中でも) 特に重要な 2 つの最適化が、レジスタ割り当てと関数のインライン化です。開発者は、アプリを 1 回以上実行して代表的なユーザー入力を行いながら実行されたコードを記録することで、このような最適化を行うときにコンパイラが適切に判断できるようサポートします。

アプリの実行に関して収集した情報をプロファイルと呼びます。コンパイラは、一部の最適化の効果を高めるために、このプロファイルを利用できます。その結果、大幅な高速化を実現できる場合もあります。このテクニックを、ガイド付き最適化のプロファイル (PGO) と呼びます。読みやすくメンテナンスしやすいコードの記述、優れたアルゴリズムの採用、データ アクセスを最大限に局所化、ロック競合の最小化など、可能な限りすべてのコンパイラ最適化を有効にしたのに満足のいくパフォーマンスを得られない場合は、このテクニックを試してみてください。一般に、PGO を使用すると、コードのパフォーマンスだけでなく、他の特性も向上します。ただし、ここで取り上げるテクニックを使って向上できるのはパフォーマンスだけです。

前回のコラム (msdn.com/magazine/mt422584) では、Microsoft Visual C++ コンパイラにおけるネイティブ PGO について詳しく説明しました。このコラムをお読みになった方には、いくつか朗報があります。マネージ PGO は使い方が簡単になりました。特に、今回取り上げるバックグラウンド JIT (別称、マルチコア JIT) が大幅に単純化されています。ですが、今回は上級者向けです。初級者向けの内容については、3 年前に CLR チームがブログ記事 (bit.ly/1ZnIj9y、英語) を執筆しています。バックグラウンド JIT は、Microsoft .NET Framework 4.5 以降のすべてのバージョンでサポートされます。

マネージ PGO には 3 つの手法があります。

  • Ngen.exe を使用してマネージ コードをバイナリ コードにコンパイル (preJIT プロセス) 後、Mpgo.exe を使用して一般的な使用シナリオを表すプロファイルを生成します。このプロファイルは、バイナリ コードのパフォーマンスの最適化に使用できます。これはネイティブ PGO に似ています。この手法を静的 MPGO と呼ぶことにします。
  • 中間言語 (IL) メソッドを初めて JIT コンパイルする直前に、インストルメント化するバイナリ コードを生成します。このバイナリ コードは、メソッドのどの部分を実行しているかについての情報を実行時に記録します。その後、このインメモリ プロファイルを使用して IL メソッドを再度 JIT コンパイルすると、高度に最適化されたバイナリ コードが生成されます。すべてが実行時に行われる点を除けば、これもネイティブ PGO に似ています。この手法を動的 MPGO と呼ぶことにします。
  • バックグラウンド JIT を使用して、IL メソッドを初めて実行する前にそのメソッドをインテリジェントに JIT コンパイルすることで、JIT のオーバーヘッドができる限り影響しないようにします。初回の呼び出しの前にメソッドを JIT コンパイルしておき、JIT コンパイラがメソッドをコンパイルするのを待つ必要がなくなるのが理想的です。

これら 3 つの手法はすべて .NET Framework 4.5 で導入され、これ以降のバージョンでもサポートされます。静的 MPGO では、Ngen.exe によって生成されたネイティブ イメージに対してのみ作用します。これに対して、動的 MPGO は IL メソッドに対してのみ作用します。静的 MPGO は動的 MPGO よりもはるかに簡単なうえ、大幅な高速化を実現できます。そのため、可能な場合は Ngen.exe を使用してネイティブ イメージを生成および最適化します。3 つ目のバックグラウンド JIT は最初の 2 つとは大きく異なり、生成するバイナリ コードのパフォーマンスが向上するのではなく、JIT コンパイルのオーバーヘッドが減少します。したがって、この手法は、他の 2 つのいずれかと組み合わせて使用することができます。ただし、バックグラウンド JIT は単独で使用しても大きな効果があり、アプリ起動時のパフォーマンスが向上します。一般的な使用シナリオの中には、50% もパフォーマンスが向上するものもあります。今回はこのバックグラウンド JIT に注目します。ここからは、IL メソッドを JIT コンパイルする従来の方法と、その方法がパフォーマンスに与える影響を取り上げます。その後、バックグラウンド JIT のしくみ、動作の理由、および正しい使用方法について説明します。

従来の JIT

.NET JIT コンパイラのプロセスについては多くの資料で取り上げられているので、おそらく .NET JIT コンパイラのしくみに関する基本的な考え方は既にご存知でしょう。とは言え、この先をスムーズに進めて、機能をしっかりと理解できるように、このトピックをもう少しだけ詳しく、かつ正確に確認しておきたいと思います。

図 1 に示す例を考えてみます。T0 がメイン スレッドです。スレッドの緑色部分は、スレッドがアプリ コードを最高速で実行していることを示します。T0 が、JIT コンパイル済みのメソッド (緑色の一番上の部分) を実行中で、次の命令が IL メソッド M0 を呼び出すとします。M0 は初回実行時には IL で表現されているため、プロセッサが実行できるように、バイナリ コードにコンパイルしなければなりません。そのため、呼び出し命令が実行されると、JIT IL スタブという関数が呼び出されます。この関数は、最終的に JIT コンパイラを呼び出して M0 の IL コードを JIT コンパイルし、生成されたバイナリ コードのアドレスを返します。これはアプリ自体の動作とは無関係なので、オーバーヘッドであることを示すため T0 では赤で表示しています。さいわい、JIT IL スタブのアドレスを格納するメモリ ロケーションは、今後同じ関数が呼び出されたときに最高速で実行されるよう、対応するバイナリ コードのアドレスに付け替えられます。

マネージ コードの実行時に従来の JIT によって生じるオーバーヘッド
図 1 マネージ コードの実行時に従来の JIT によって生じるオーバーヘッド

M0 から戻ると、JIT コンパイル済みのコードがさらにいくつか実行された後、IL メソッド M1 を呼び出します。M0 とまったく同様に、JIT IL スタブが呼び出され、スタブが JIT コンパイラを呼び出してメソッドをコンパイルし、バイナリ コードのアドレスを返します。M1 から戻ると、さらにいくつかのバイナリ コードが実行された後、さらに 2 つのスレッド (T1 と T2) の実行を開始します。ポイントはここからです。

JIT コンパイル済みのメソッドを実行した後、T1 と T2 は IL メソッド M3 を呼び出します。M3 は初めて呼び出されるため、JIT コンパイルが必要です。JIT コンパイラは、内部で JIT コンパイルされているかどうかを示す全メソッドのリストを管理しています。リストは、AppDomain ごとに 1 つと、共有コードに 1 つ作成されます。複数のスレッドが同時に JIT コンパイルに関係しても安全であるよう、このリストはロックで保護され、各要素もそれぞれ独自のロックで保護されます。この例の場合、1 つのスレッド (T1) がメソッドを JIT コンパイルし、無駄な時間を費やしてアプリと無関係な動作を実行している間、T2 は M3 のバイナリ コードが使用可能になるまで何も実行しない (実行するコードがないのでロックを待機) という状況が発生します。同時に T0 が M2 をコンパイルしているとします。スレッドは、メソッドの JIT コンパイルが終了すると、JIT IL スタブのアドレスをバイナリ コードのアドレスに付け替え、ロックを解除してこのメソッドを実行します。その後、ロックを解除された T2 が再開し、M3 を実行します。

これらのスレッドが実行するコードの残りの部分を緑色のバーで示します (図 1 参照)。つまり、アプリは最高速度で実行されます。新しいスレッド T3 の実行が開始されても、必要なすべてのメソッドが既に JIT コンパイル済みなので、T3 は最高速度で実行されます。最終的なパフォーマンスは、ネイティブ コードのパフォーマンスに非常に近くなります。

極端に言うと、この図の赤い部分の実行にかかる時間は、メソッドを JIT コンパイルする時間によって決まります。この JIT コンパイル時間を決めるのは、メソッドのサイズと複雑さです。この時間には、(必要なアセンブリやモジュールを読み込む時間を除いて) 数マイクロ秒から数十ミリ秒の幅があります。アプリの起動時に初めて実行するメソッドの数が 100 未満なら、大した問題ではありません。しかし、メソッドの数が数百~数千に上る場合、結果として生じる赤い部分をすべて併せると、影響はかなり大きくなります。メソッドの JIT コンパイル時間が実行時間に匹敵するときは、この影響が顕著に現れ、2 桁の割合で速度が低下します。たとえば、アプリの起動時に、平均 JIT コンパイル時間が 3 ミリ秒の異なるメソッドを 1,000 個実行する必要があれば、起動の完了に 3 秒かかることになります。これは大きな問題です。これでは顧客の満足を得られず、ビジネスには不適切です。

複数のスレッドが同じメソッドを同時に JIT コンパイルする可能性があります。また、最初の JIT コンパイルが失敗し、2 回目で成功することもあります。さらに、JIT コンパイル済みのメソッドが再度 JIT コンパイルされる場合も考えられます。ただし、こうした状況については今回取り上げません。バックグラウンド JIT を使用する場合にこのような状況を意識する必要はありません。

バックグラウンド JIT

ここまで説明してきた JIT コンパイルのオーバーヘッドは、なくしてしまうことも、大幅に削減することもできません。IL メソッドを実行するには、JIT コンパイルしなければなりません。ですが、このオーバーヘッドが発生するタイミングを変えることはできます。ポイントは、IL メソッドが呼び出されて初めて JIT コンパイルするのではなく、事前にメソッドを JIT コンパイルしておき、呼び出される前にバイナリ コードを生成しておくことです。これが正しく行えれば、図 1 に示したすべてのスレッドが緑になり、NGEN ネイティブ イメージを実行しているように、あるいはそれ以上に最高速で実行されます。しかし、これを実現するには、解決しなければならない問題が 2 つあります。

最初の問題は、事前にメソッドを JIT コンパイルする場合、どのスレッドでこれを実行するかです。この問題を解決する最善の方法は、バックグラウンドで実行する専用スレッドと、できる限り高速に実行される JIT メソッドを用意することです。結論から言うと、使用可能なコアが 2 つ以上なければこれは機能しません (ほぼすべての場合にこの条件が課せられます)。コアが 2 つ以上あれば、アプリ コードの実行と JIT コンパイルをオーバーラップさせて、JIT コンパイルのオーバーヘッドを目立たなくすることができます。

もう 1 つの問題は、メソッドが初めて呼び出される前に、JIT コンパイルするメソッドを決める方法です。通常、すべてのメソッドに条件付きメソッド呼び出しが行われるため、呼び出される可能性のあるすべてのメソッドを JIT コンパイルしたり、次に JIT コンパイルするメソッドの選択に頭を悩ます必要はありません。大半の場合、極めて早い段階で JIT バックグラウンド スレッドはアプリ スレッドに追いつかなくなります。ここで必要なのがプロファイルです。最初にアプリの起動と一般的な使用シナリオを実行し、JIT コンパイルされるメソッドとその順序をシナリオごとに個別に記録しておきます。その後、記録したプロファイルと一緒にアプリを発行することで、ユーザーのコンピューターでアプリが実行されるときに、実測時間 (ユーザーが認識する時間とパフォーマンス) に影響を及ぼす JIT コンパイルのオーバーヘッドを最小限に抑えることができます。この機能をバックグラウンド JIT と呼び、ごく簡単な操作だけで使用することができます。

先ほど、JIT コンパイラが別のスレッドでさまざまなメソッドを並列に JIT コンパイルする例を見ました。つまり、技術的には従来の JIT も既にマルチコア対応でした。MSDN のドキュメントでは、明確な特性に基づいてではなく、2 つ以上のコアが必要なことから、この機能をマルチコア JIT と呼んでいます。これは残念なことで、紛らわしい表現です。ここでは「バックグラウンド JIT」という呼び名が定着することを願ってこれを使用します。PerfView には、この機能の組み込みのサポートが用意されており、バックグラウンド JIT という名前が使われています。「マルチコア JIT」という呼称は、マイクロソフトが開発初期に使用していた名前です。ここからは、この手法を独自のコードに適用するのに必要な作業と、この手法によって従来の JIT モデルがどのように変化するかを説明します。また、アプリでバックグラウンド JIT を使用した場合のメリットを、PerfView を使用して評価する方法も紹介します。

バックグラウンド JIT を使用するには、(多数の JIT コンパイルをトリガーするシナリオごとに 1 つずつ) プロファイルの場所をランタイムに指示する必要があります。ランタイムがプロファイルを読み取り、バックグラウンド スレッドでコンパイルするメソッドを特定できるように、使用するプロファイルもランタイムに指示する必要があります。当然ですが、この操作は関連する使用シナリオが始まる前に済ませておかなくてはなりません。

プロファイルの場所を指定するには、mscorlib.dll で定義されている System.Runtime.ProfileOptimization.SetProfileRoot メソッドを次のように呼び出します。

public static void SetProfileRoot(string directoryPath);

唯一のパラメーター directoryPath で、すべてのプロファイルの読み取り元または書き込み先となるフォルダーのディレクトリを指定します。同じ AppDomain では、このメソッドの初回呼び出しだけが有効で、他の呼び出しはすべて無視されます (ただし、同じパスを別の AppDomain から使用することはできます)。また、コンピューターにコアが 2 つ以上搭載されていない場合、SetProfileRoot の呼び出しはすべて無視されます。このメソッドは、指定されたディレクトリを、必要に応じて後で使用できるように内部変数に格納するだけです。通常、初期化中にプロセスの実行可能ファイル (.EXE) からこのメソッドが呼び出されます。共有ライブラリから呼び出してはいけません。このメソッドはアプリが実行されていれば、どの時点でも呼び出すことができますが、ProfileOptimization.StartProfile メソッドを呼び出す前に限ります。StartProfile メソッドは次のように呼び出します。

public static void StartProfile(string profile);

パフォーマンスの最適化を行う実行パス (起動時など) にアプリが移行しようとするタイミングでこのメソッドを呼び出し、プロファイルのファイル名と拡張子を渡します。ファイルが存在しなければプロファイルが記録され、SetProfileRoot を使用して指定したフォルダーのファイルに指定の名前で格納されます。このプロセスを、「プロファイルの記録」と呼びます。指定したファイルが存在し、有効なバックグラウンド JIT プロファイルが含まれていると、プロファイルの内容に応じて選択された、専用のバックグラウンド スレッド JIT コンパイル メソッドでバックグラウンド JIT が実行されます。このプロセスを、「プロファイルの再生」と呼びます。プロファイルを再生している間も、アプリの動作が記録され、同じ入力プロファイルが置き換えられます。

記録されていないプロファイルを再生することはできません。現時点では単純にサポートされていません。StartProfile は、各実行パスに適したさまざまなプロファイルを指定して、何回でも呼び出すことができます。SetProfileRoot を使用してプロファイル ルートを初期化する前にこのメソッドが呼び出された場合、メソッドは何も実行しません。また、指定した引数がなんらかの形で無効になった場合も、両方のメソッドが実行されません。実際、好ましくない形でアプリの動作に影響を与えないよう、これらのメソッドでは例外がスローされることもエラー コードが返されることもありません。フレームワークの他の静的メソッドと同様、どちらのメソッドもスレッドセーフです。

たとえば、起動時のパフォーマンスを向上したければ、main 関数の最初の手順としてこれらの 2 つのメソッドを呼び出します。特定の使用シナリオのパフォーマンスを向上する場合は、ユーザーがそのシナリオを開始するタイミングで StartProfile を呼び出し、それより前の任意の時点で SetProfileRoot を呼び出します。すべての操作は、AppDomain でローカルに実行されます。

コードでバックグラウンド JIT を使用するために必要な作業はこれだけです。非常に簡単な作業で済むので、有効かどうかを深刻に悩まず、まずは試してください。その後、高速になったかどうか評価して、この手法を続ける価値があるかどうかを判断します。高速化の割合が 15% 以上あれば、継続することをお勧めします。15% 未満の場合は皆さんの判断次第です。ここからは、このプロセスのしくみを説明します。

現在コードを実行している AppDomain のコンテキストでは、StartProfile を呼び出すたびに以下の操作が実行されます。

  1. プロファイル (存在する場合) を含むファイルのコンテンツをすべてメモリにコピーし、ファイルを閉じます。
  2. 以前にも StartProfile が正常に呼び出されている場合は、既に実行中のバックグラウンド JIT スレッドがあります。この場合は、そのバックグラウンド JIT スレッドを終了し、新しいバックグラウンド スレッドを作成します。その後、StartProfile を呼び出したスレッドを呼び出し元に返します。
  3. この手順は、バックグラウンド JIT スレッド内で実行されます。プロファイルを解析します。記録されたメソッドを、記録されたとおりの順序で可能な限り迅速に JIT コンパイルします。この手順は、プロファイルの再生プロセスを表します。

バックグラウンド スレッドについては以上です。記録されたすべてのメソッドの JIT コンパイルが完了すると、バックグラウンド スレッドは自動的に終了します。メソッドの解析や JIT コンパイルで問題が発生しても、スレッドは自動的に終了します。メソッドを JIT コンパイルするのに必要なアセンブリやモジュールがまだ読み込まれていない場合は、そのアセンブリやモジュールを読み込むのではなく、JIT コンパイルを行いません。バックグラウンド JIT は、プログラムの動作をできる限り変更しないように設計されています。モジュールが読み込まれるときに、そのモジュールのコンストラクターが実行されます。モジュールが見つからない場合は、System.Reflection.Assembly.ModuleResolve イベントに登録されたコールバックが呼び出されます。そのため、モジュールが見つかる前にバックグラウンド スレッドがモジュールを読み込むと、これらの関数の動作が変わってしまう可能性があります。これは、System.AppDomain.AssemblyLoad イベントに登録されたコールバックでも同様です。バックグラウンド JIT は必要なモジュールを読み込まないため、記録されたメソッドの多くがコンパイルできない可能性があります。この場合に得られるメリットはわずかです。

より多くのメソッドを JIT コンパイルするには、バックグラウンド スレッドを複数作成すれば効果が上がると考えがちです。これを行わない理由は、まず、バックグラウンド スレッドはコンピューティング リソースを集中的に使用し、アプリ スレッドとリソースを競合する可能性があるためです。次に、こうしたスレッドを増やすと、スレッド同期での競合が増加します。また、メソッドを JIT コンパイルしても、時には、そのメソッドがどのアプリ スレッドからも呼び出されない場合もあります。逆に、初めて呼び出されるメソッドが、プロファイルに記録されていないかもしれませんし、マルチコア スレッドによって JIT コンパイルされる前かもしれません。こうした問題により、バックグラウンド スレッドを複数用意してもあまり効果は上がりません。ですが、今後 (特にモジュールの読み込みに関する制約が緩和された場合は) CLR チームがこれを検討する可能性はあります。ここからは、プロファイルの記録プロセスを含め、アプリ スレッドで行われる状況について説明します。

図 2 では、バックグラウンド JIT コンパイルが有効になっていることを除いて、図 1 と同じ例を示しています。つまり、メソッド M0、M1、M3、および M2 を、この順序で JIT コンパイルするバックグラウンド スレッドが存在します。このバックグラウンド スレッドは、アプリ スレッド T0、T1、T2、および T3 と競合しています。メソッドがその目的を果たすためにいずれかのスレッドから初めて呼び出される前に、バックグラウンド スレッドはすべてのメソッドの JIT コンパイルを完了しておく必要があります。ここからの説明では、この条件が M0、M1、M3 には当てはまり、M2 には当てはまらないものとします。

バックグラウンド JIT 最適化を示す例 (図 1 との比較)
図 2 バックグラウンド JIT 最適化を示す例 (図 1 との比較)

T0 が M0 を呼び出そうとする時点で、M0 は既に JIT スレッドによって JIT コンパイルが完了しています。ただし、メソッドのアドレスはまだ付け替えられておらず、JIT IL スタブを参照した状態です。バックグラウンド JIT スレッドがアドレスを付け替えることはできますが、メソッドが呼び出されるかどうかを後で判断する目的で付け替えることはありません。この情報は、CLR チームがバックグラウンド JIT を評価するために使用します。したがって、JIT IL スタブが呼び出され、メソッドがバックグラウンド スレッドで既にコンパイルを完了しているかどうかは、このスタブが判断します。JIT IL スタブは、アドレスを付け替え、メソッドを実行するだけです。このスレッドでの JIT コンパイルのオーバーヘッドは完全に取り除かれます。M1 が T0 で呼び出される時点で、M1 にも同じ処理が行われます。M3 が T1 で呼び出される時点も同じです。しかし、T2 が M3 を呼び出す際は (図 1 参照)、メソッドのアドレスが T1 によって既に付け替えられているため、T2 はメソッドの実際のバイナリ コードを直接呼び出します。その後、T0 が M2 を呼び出します。ですが、バックグラウンド JIT スレッドがまだ JIT コンパイルを完了していないため、T0 はメソッドに JIT ロックをかけて待機します。メソッドが JIT コンパイルされると、T0 が再開されて、そのメソッドを呼び出します。

メソッドをプロファイルに記録するプロセスについてまだ説明していません。バックグラウンド JIT スレッドが JIT コンパイルをまだ開始していない (または、プロファイルに含まれていないために JIT コンパイルを行わない) メソッドを、アプリ スレッドが呼び出す可能性は十分にあります。アプリ スレッドが、まだ JIT コンパイルされていない静的 IL メソッドまたは動的 IL メソッドを呼び出す場合、次のアルゴリズムが実行されます。

  1. メソッドが存在する AppDomain の JIT リストをロックします。
  2. 他のアプリ スレッドがバイナリ コードを既に生成している場合は、JIT リストのロックを解除して手順 13. に進みます。
  3. メソッドがリストに含まれていない場合は、そのメソッドの JIT ワーカーを表す新しい要素をリストに追加します。メソッドが既に存在する場合は、参照カウントをインクリメントします。
  4. JIT リストのロックを解除します。
  5. メソッドに JIT ロックをかけます。
  6. 他のアプリ スレッドがバイナリ コードを既に生成している場合は、手順 11. に進みます。
  7. メソッドがバックグラウンド JIT の対象になっていない場合は、この手順はスキップします。現状、バックグラウンド JIT が対象とするのは、System.Reflection.Assembly.Load で読み込まれなかったアセンブリで定義されている、静的に出力された IL メソッドだけです。メソッドが対象になっている場合は、バックグラウンド JIT スレッドがこのメソッドの JIT コンパイルを完了しているかどうかをチェックします。メソッドが対象になっていて、JIT コンパイルが完了している場合は、このメソッドを記録して手順 9. に進みます。それ以外の場合は次の手順に進みます。
  8. メソッドを JIT コンパイルします。JIT コンパイラがメソッドの IL を調べ、必要なすべての型を決定して、必要なアセンブリをすべて読み込み、必要な型オブジェクトをすべて作成します。問題が発生した場合は例外がスローされます。ほとんどのオーバーヘッドはこの手順で生まれます。
  9. JIT IL スタブのアドレスを、メソッドの実際のバイナリ コードのアドレスに付け替えます。
  10. バックグラウンド JIT スレッドではなく、アプリ スレッドがメソッドを JIT コンパイルしている場合は、アクティブなバックグラウンド JIT レコーダーが存在し、メソッドはバックグラウンド JIT によってサポートされ、メソッドはインメモリ プロファイルに記録されます。メソッドが JIT コンパイルされる順序は、プロファイルで管理されています。生成されるバイナリ コードは記録されません。
  11. メソッドの JIT ロックを解除します。
  12. リスト ロックを使用して、メソッドの参照カウントを安全にデクリメントします。参照カウントが 0 になると、その要素は削除されます。
  13. メソッドを実行します。

バックグラウンド JIT の記録プロセスは、以下のいずれかの状況が発生すると終了します。

  • バックグラウンド JIT マネージャーに関連付けられた AppDomain が、なんらかの理由でアンロードされる。
  • 同じ AppDomain で StartProfile が再度呼び出される。
  • メソッドがアプリ スレッドで JIT コンパイルされる割合が非常に低くなった (この状況は、JIT コンパイルがほとんど不要な安定した状態に達していることを意味します。バックグラウンド JIT は、この時点以降のすべてのメソッドの JIT コンパイルを行いません)。
  • 記録に関するいずれかの上限に到達した (モジュール数の上限は 512、メソッド数の上限は 16,384、連続記録の最長時間は 1 分です)。

記録プロセスが終了すると、記録済みのインメモリ プロファイルを指定のファイルに出力します。その結果、アプリの次回実行時に、前回の実行時に確認した動作を反映したプロファイルが選択されます。既に触れたように、プロファイルは常に上書きされます。現在のプロファイルを保持する場合は、StartProfile を呼び出す前にプロファイルを手動でコピーしなければなりません。通常、プロファイルのサイズが数十キロバイトを超えることはありません。

このテーマの最後として、プロファイルのルートの選択について触れておきます。クライアント アプリの場合は、ユーザーごとに異なるセットのプロファイルを使用するか、全ユーザーに 1 セットのプロファイルを使用するかによって、ユーザー固有のディレクトリかアプリ相対のディレクトリのいずれかを選択します。ASP.NET アプリや Silverlight アプリの場合は、通常、アプリ相対のディレクトリを使用します。実際、ASP.NET 4.5 と Silverlight 4.5 以降では、バックグラウンド JIT が既定で有効になり、プロファイルはアプリと一緒に保存されます。ランタイムは、SetProfileRoot と StartProfile を main メソッドから呼び出したかのように動作するため、何もしなくてもこの機能が利用されます。ただし、既に説明したように、StartProfile は呼び出すことが可能です。.NET ブログ記事「An Easy Solution for Improving App Launch Performance」(アプリの起動パフォーマンスを向上するための簡単なソリューション、bit.ly/1ZnIj9y、英語) に記載しているように、Web 構成ファイルで profileGuidedOptimizations フラグを「None」に設定すると、自動バックグラウンド JIT を無効にすることができます。このフラグに設定できる値は、他には「All」だけです。「All」はバックグラウンド JIT を有効にします (既定の設定)。

バックグラウンド JIT の動作

バックグラウンド JIT は、Windows イベント トレーシング (ETW) プロバイダーです。したがって、この機能に関連するイベントの数を、Windows Performance Recorder や PerfView などの ETW コンシューマーにレポートします。これらのイベントを利用して、バックグラウンド JIT で発生した非効率性や障害を診断できます。具体的には、バックグラウンド スレッドでコンパイルされたメソッドの数や、そのメソッドをJIT コンパイルするのにかかった合計時間を特定できます。PerfView は bit.ly/1PpJUpv (英語) からダウンロードできます (インストールは必要ありません。zip ファイルを解凍して実行してください)。ここでは、次のシンプルなコードを使用して、デモを行います。

class Program {
  const int OneSecond = 1000;
  static void PrintHelloWorld() {
    Console.WriteLine("Hello, World!");
  }
  static void Main() {
    ProfileOptimization.SetProfileRoot(@"C:\Users\Hadi\Desktop");
    ProfileOptimization.StartProfile("HelloWorld Profile");
    Thread.Sleep(OneSecond);
    PrintHelloWorld();
  }
}

main 関数では、SetProfileRoot と StartProfile を呼び出してバックグラウンド JIT をセットアップしています。スレッドを約 1 秒間スリープ状態にしてから、PrintHelloWorld メソッドを呼び出します。このメソッドは、Console.WriteLine を呼び出すだけで戻ります。このコードを IL の実行可能ファイルにコンパイルします。Console.WriteLine の JIT コンパイルは不要です。コンピューターに NET Framework をインストールするときに、NGEN を使用して既にコンパイルが完了しています。

PerfView を使用して実行可能ファイルを起動し、プロファイルを開始します (この方法の詳細については、.NET ブログ「Improving Your App’s Performance with PerfView」(PerfView を使用してアプリのパフォーマンスを向上する、bit.ly/1nabIYC、英語) または Channel 9 PerfView Tutorial (bit.ly/23fwp6r、英語) を参照してください)。この機能からイベントをキャプチャできるようにするため、[Background JIT] (バックグラウンド JIT) チェック ボックスを必ずオンにします (.NET Framework 4.5 と 4.5.1 でのみ必要)。PerfView が完了するまで待機し、JITStats ページを開きます (図 3 参照)。プロセスがバックグラウンド JIT コンパイルを使用していないことが PerfView に表示されます。初回実行時にプロファイルを生成する必要があるのは、このためです。

PerfView の JITStats の場所
図 3 PerfView の JITStats の場所

この時点でバックグラウンド JIT プロファイルが生成されているので、PerfView を使用して実行可能ファイルを起動し、プロファイルを開始します。ただし今回は、JITStats ページを開くと、1 つのメソッド (PrintHelloWorld) がバックグラウンド JIT スレッドで JIT コンパイルされていて、1 つのメソッド (Main) が JIT コンパイルされていないことが示されます。また、すべての IL メソッドを JIT コンパイルするのにかかった時間の約 92% がアプリ メソッドで発生していることも示されます。PerfView レポートでは、JIT コンパイルされたすべてのメソッドのリスト、各メソッドの IL とバイナリ サイズ、メソッドを JIT コンパイルしたユーザーなどの情報も表示されます。さらに、バックグラウンド JIT イベントについてまとめられた一連の情報にも簡単にアクセスできます。ですが、今回はスペースが足りないので、詳しくは説明しません。

読者の中には、約 1 秒間スリープ状態にする目的を疑問に思っている方もいるかもしれません。このスリープ時間は、バックグラウンド スレッドが PrintHelloWorld を JIT コンパイルするために必要な時間です。この時間を確保しておかないと、アプリ スレッドは、バックグラウンド スレッドの前にメソッドのコンパイルを開始します。つまり、ほぼすべての場合、バックグラウンド スレッドが先に実行されるよう、早い段階で StartProfile を呼び出さなければなりません。

まとめ

バックグラウンド JIT は、.NET Framework 4.5 以降でサポートされるガイド付き最適化のプロファイルです。今回は、この機能について知っておくべき情報のほとんどを取り上げました。この最適化が必要な理由、最適化のプロセス、この最適化を独自のコードで適切に使用する方法について詳しく示しました。この機能は、NGEN が有用でない場合や使用できない場合に便利です。簡単に使用できるので、アプリにとって役に立つかどうかをあまり深刻に考えずに、まず試してみてください。実現した高速化が満足いくものであれば、続けてください。満足できなければ、削除するのは簡単です。マイクロソフトは、バックグラウンド JIT を使用してアプリの起動パフォーマンスをいくつか改善しています。この最適化をアプリで効果的に使用し、JIT を多用するシナリオやアプリの起動において、大幅な高速化を実現していただければさいわいです。


Hadi Brais は、インド工科大学デリー校で博士号を取得した研究者で、次世代のメモリ テクノロジ向けのコンパイラ最適化について研究しています。彼は、自身の時間のほとんどを C/C++/C# のコード作成や、ランタイム、コンパイラ フレームワーク、およびコンピューター アーキテクチャの詳細な調査に費やしています。彼のブログは hadibrais.wordpress.com (英語) です。連絡先は hadi.b@live.com (英語のみ) です。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Vance Morrison に心より感謝いたします。