Burst インスペクターの検索パフォーマンスの改善

私は Jonas Reholt、Burst チームで働いている学生です。このブログでは、Burst インスペクターの最近のパフォーマンス変更を可能にした、最適化の経験についてご紹介します。現在、Burst インスペクターの検索速度は 13 倍になっていて、開発者はプロジェクトを最適化する際に、より素速く必要なコードに専念できます。
以下では、Unity Profiler を使用してプログラムのパフォーマンスにおけるボトルネックを調査し、修正する方法についてご紹介します。
Unity Burst コンパイラーは、C# コードを高度に最適化されたアセンブリコードに変換します。Burst インスペクターを使用すると、Unity エディターで直接アセンブリコードを確認できるので、単純なコード検査のために外部ツールを使用する必要はありません。
Burst インスペクターを起動し、表示するターゲットジョブを選択すると、以下の画像のようなウィンドウが表示されます。

ご覧のとおり、Burst インスペクターでは、構文強調や分岐フローを示す矢印などが表示されます。
インスペクターは、選択中のターゲット関数を実装しているアセンブリに向かってスクロールしようとしますが、特定の指示やコメントなどをアセンブリビューで検索するのにも便利です。ここで、このブログのテーマに戻りましょう。
検索を行うにあたり、インスペクターは元のアセンブリ出力を検索し、これらのインデックスをインスペクタービュー内の位置に変換する必要があります。従来の検索機能は以下のパターンに従っていて、System.String.IndexOf(*) の実装に大きく依存していました。
while (assemblyCode.IndexOf(key, accIdx) >= 0) {
// ...
// Do logic for handling search hits
// ...
}
一般的な検索でヒットした 135,582 行のアセンブリコード(合計21,769件)に対して上記の検索を実行した結果、初回の検索の実行に要した時間は約 12 秒、後続の検索では約 5 秒でした。GUI イベントの待ち時間としては望ましくなかったため、改善する必要がありました。Unity プロファイラーで検索を実行したところ、以下のように、実行時間の 37.3% が IndexOf(*) に費やされていることがわかりました。

賢明な最適化を行うにあたり、この関数への依存に対処するため、カスタム実装を行うか、アルゴリズムを完全に変更することが必要でした。どのようなアルゴリズムが使われるにせよ、全ての文字列を通過することになります。そのため、一致を検出するためのカスタム実装が必要でした。これを考慮すると、元のアルゴリズムを維持しながら、カスタム IndexOf 関数を作成して最適化を開始するのが適切であるように思われます。
LongTextArea.GetFragNrFromBlockIdx() の処理に費やされた 3.34 秒は、色分けされていないアセンブリコードを取得することに起因していました。これは、検索を行うのに使われています。現在、Burst インスペクターはアセンブリコードを 2 回保存します。1 回はレンダリング用にフォーマットされ、もう 1 回はフォーマットされません。
現在は検索にヒットするたびに呼び出しがあり、さらにもう 1 回呼び出しがありますが、カスタム関数を作成すると、嬉しいことに呼び出し回数を削減することもできます。
IndexOf(*) のソースコードを見ると、全体的に堅牢な実装を行うために必要な多くの安全チェックがあることがわかります。しかし、ここではこれらのチェックのほとんどが true であると仮定して問題ないでしょう。パフォーマンスを最大限高めるためには、境界チェックなどを避けるために C 言語のような関数を作るのがベストです。
IsKeyMatch(*) がキーが一致するかどうかをチェックする、以下の擬似コードを使って関数を作ることができます。
List<int> Search(string assemblyCode, string key, int accIdx) {
var hits = new List<int>();
for (i = accIdx; i < assemblyCode.len - key.len; i++) {
if (IsKeyMatch(assemblyCode, key, i)) {
hits.add(i);
i += key.len-1;
}
}
return hits;
}
しかし、C# はマネージド言語なので、この C 言語のような関数においては、ガベージコレクタがメモリアドレスを再配置しないように、使用するマネージオブジェクトを固定する必要があります。定型コードは以下の通りです。
unsafe {
fixed (char* source = assemblyCode) {
fixed (char* needle = key) {
CustomIndexOf(source, key)
}
}
}
これらを組み合わせることで、元の while ループをインデックスファインダーの呼び出しと検索ヒット処理のロジックに分けることができます。
matches = FindAllMathces(text, key)
foreach match {
...
Do logic for handling search hits
...
}
ここで得られたものは何でしょうか。先ほどの例を挙げると、このコード変更によって、最初の呼び出し速度は 6.6 倍、後続の呼び出し速度は 13.2 倍になりました(新旧比較)。最初の検索における速度の向上が少ないのは、フォーマットされていないアセンブリをロードし、色文字列の一致を見つけることを避けるオーバーヘッドに起因します。

これらの改善のおかげで、ヒット数 22,000 件弱の高負荷検索において、最初の検索は約 1.8 秒、後続の検索は約 0.4 秒で実行できるようになりました。検索をするたびにお茶を淹れられるほどの待ち時間が生じる事態を解消できたので、Burst インスペクターは大規模なアセンブリにおいてより使いやすくなりました。
このパフォーマンス改善は、Burst 1.8.7 パッケージでご利用いただけます。
Burst についてさらに詳しく知りたい方は、Burst フォーラムに投稿をお寄せください。また、現在連載中の Tech from the Trenches シリーズの、他の Unity 開発者による新しい技術ブログもぜひご覧ください。