Async Upload Pipeline(AUP)でローディングのパフォーマンスを最適化する

ローディング画面が好きという人は誰もいないでしょう。ロード時間は Async Upload Pipeline(AUP)パラメーターを簡単に調節するだけで大幅に改善できることをご存知でしたか?本記事では、AUP によるメッシュとテクスチャをローディングする仕組みを詳しくご紹介します。ロード時間を大幅に短縮する上で、この仕組みを理解しておくと役に立つと思います。これによって実際にパフォーマンスが 2 倍改善されたプロジェクトもあります。
AUP が機能する技術的な仕組みや、その効果を最大限に引き出すために使う API を知りたい方は、ぜひ以下を読み進めてください。
最適化された最新版の AUP の実装は Unity 2018.3 ベータ版でご使用いただけます。
まず最初に、AUP がどんな場合に使用されるか、およびローディング処理のプロセスについて、詳しくご紹介します。
バージョン 2018.3 以前は AUP はテクスチャのみを扱っていました。2018.3 ベータ版からはテクスチャとメッシュがロード可能になりましたが、一部例外もあります。読み込み/書き込み有効のテクスチャとメッシュ、および Compressed(圧縮)メッシュには AUP は使用できません。([注]バージョン 2018.2 で追加された Texture Mipmap Streaming も AUP を使用します。)
ビルド処理中、テクスチャやメッシュオブジェクトはシリアライズされたファイルに書き込まれ、大きなバイナリデータ(テクスチャあるいは頂点データ)は付随の .resS ファイルに書き込まれます。このレイアウトはプレイヤーデータとアセットバンドルの両方に当てはまります。オブジェクトとバイナリデータを分けることにより(通常、小さなオブジェクトが入る)シリアライズされたファイルのより高速な読み込みが可能になり、大きなバイナリデータは .resS から効率的に読み込めるようになります。テクスチャオブジェクトやメッシュオブジェクトは、デシリアライズされた時に AUP のコマンドキューへコマンドをサブミットします。コマンドが完了した時点で、そのテクスチャあるいはメッシュデータは GPU へのアップロードされており、オブジェクトがメインスレッドに統合可能になっています。

アップロード処理中、.resS ファイルからの大きなバイナリデータは固定サイズのリングバッファに読み込まれます。一旦メモリに入ったデータはレンダースレッド上でタイムスライス方式で GPU にアップロードされます。システムの挙動を変えるために変更できる 2 つのパラメーターは、リングバッファと、タイムスライスの長さです。
AUP では各コマンドごとに以下の処理が行われます。
必要な量のメモリがリングバッファ内で使用可能になるまで待機する
ソースの .resS ファイルから、割り当てられたメモリ内にデータを読み込む
ポストプロセッシング(テクスチャ解凍、メッシュコリジョン生成、プラットフォームごとの調整など)を実行する
タイムスライス方式でレンダースレッドにアップロードする
リングバッファメモリを解放する
複数のコマンドが同時に進行可能ですが、すべてのコマンドが 1 つの共有されたリングバッファにそれぞれ必要なメモリを割り当てる必要があります。リングバッファが一杯になると、新しいコマンドは待機することになります。この待機は非同期読み込み処理の速度を遅くしますが、メインスレッドのブロックを引き起こしたりフレームレートに影響を及ぼしたりすることはありません。
以下は、それぞれの特徴を簡単に整理したものです。
読み込みパイプラインの比較 AUP なし AUP 効果 メモリ使用 データをデフォルトヒープから読み出しながら割り当てる(ハイウォーターマーク) 固定サイズのリングバッファ ハイウォーターマークの削減 アップロード処理 データが使用可能になると同時にアップロード 固定タイムスライスで少しずつアップロード ヒッチのないアップロード ポストプロセッシング 読み込みスレッドで実行(読み込みスレッドがブロックされる) バックグラウンドのジョブで実行 高速なローディング
バージョン 2018.3 で AUP を最大限に活用するために、ランタイムで調整できる 3 つのパラメーターがあります。
- QualitySettings.asyncUploadTimeSlice ― 各フレームで、レンダースレッドでテクスチャおよびメッシュデータの読み込みに使用される時間(ミリ秒単位)です。非同期ロード処理の進行中は、このサイズのタイムスライスを 2 つ実行します。デフォルトの値は 2 ms(2 ミリ秒)です。この値が小さすぎると、テクスチャやメッシュの GPU アップロードが遅くなる場合があります。逆に値が大きすぎるとフレームレートのヒッチが起きることがあります。
- QualitySettings.asyncUploadBufferSize ― リングバッファのサイズ(メガバイト単位)です。各フレームでアップロードのタイムスライスが発生した場合に、タイムスライス全体を活用するための十分なデータ量がリングバッファ内に必要です。リングバッファが小さ過ぎると、アップロードタイムスライスが短縮されます。デフォルトは、Unity 2018.2 では 4MB でしたが Unity 2018.3 では 16MB に増加されました。
- QualitySettings.asyncUploadPersistentBuffer ― Unity 2018.3 で搭載されるこのフラグは、すべての保留された読み出しが完了した時にアップロードリングバッファを解放するかどうかを設定します。このバッファの割り当てと解放はしばしばメモリの断片化を引き起こすことがあるので、通常はデフォルト(true)のままにしておくことが推奨されます。読み込み中以外でのメモリの再割り当てがどうしても必要な場合は、この値を false に設定することも可能です。
これらの設定はスクリプティング API を使うか、または Quality Settings(品質設定)メニューから調整可能です。

大量のテクスチャとメッシュを、AUP でデフォルトのタイムスライス(2 ms)およびリングバッファ(4 MB)でアップロードする場合の負荷を見てみましょう。読み込みが行われているので、1 レンダーフレームごとに 2 回のタイムスライスが入るため、アップロード時間は 4 ms 取られるはずです。しかし、プロファイラーのデータを見ると、約 1.5 ms しか使われていません。また、リングバッファ内でメモリが使用可能になっているので、アップロード直後に新しい読み出し処理が発行されているのも確認できます。このことから、より大きなリングバッファが必要であることが分かります。

試しにリングバッファを増加させてみましょう。ローディング画面なので、アップロードタイムスライスも増加させた方が良いでしょう。リングバッファを 16 MB、タイムスライスを 4 ms とすると、以下のようになります。

今度は、ほぼすべてのレンダースレッドの時間がアップロードに使用されており、アップロードとアップロードの間のわずかな時間がレンダリングに使用されていることが分かります。
以下は、この負荷サンプルでアップロードタイムスライスとリングバッファサイズの設定を変えた場合の、それぞれのロード時間を比較したグラフです。テストは MacBook Pro(2.8GHz Intel Core i7 搭載、OS X El Capitan)で実行されました。アップロード速度と入出力速度はプラットフォームやデバイスによって異なります。負荷サンプルとして使用したのは、Unity 社内でパフォーマンステストに使用されるサンプルプロジェクト『Viking Village』のサブセットです。他に読み込まれているオブジェクトがあるので、値を変更したときにどの程度パフォーマンスが改善したかは正確に確認できません。しかし、このサンプルの場合、4 MB・2 ms から 16 MB・4 ms に変更すると、テクスチャとメッシュの読み込みが少なくとも 2 倍以上速くなることは確実です。
各パラメーターを変更してみると、以下のような結果になります。

したがって、このサンプルプロジェクトでロード時間を最適化できる設定は以下のようになります。
QualitySettings.asyncUploadTimeSlice = 4
QualitySettings.asyncUploadBufferSize = 16
QualitySettings.asyncUploadPersistentBuffer = true
テクスチャとメッシュの読み込み速度を最適化するときの推奨事項は下記のとおりです。
- フレーム落ちを発生させない範囲で、最も大きい QualitySettings.asyncUploadTimeSlice を設定する。
- ローディング画面中、一時的に QualitySettings.asyncUploadTimeSlice を増加させる。
- プロファイラーでタイムスライスの使用状況を調査する。タイムスライスはプロファイラー上で AsyncUploadManager.AsyncResourceUpload として表示される。タイムスライスがフル活用されていない場合は QualitySettings.asyncUploadBufferSize を増加させる。
- 基本的に QualitySettings.asyncUploadBufferSize が大きいほうが速度が上がるので、メモリが十分にある場合は 16 MB から 32 MB に増加させる。
- ローディング中以外にどうしてもランタイム使用メモリを削減しなければならない特別な理由がなければ、QualitySettings.asyncUploadPersistentBuffer の設定を true のままにする。
Q: タイムスライスされたアップロードがレンダースレッドで起こる頻度はどの程度ですか?
- タイムスライスされたアップロードは、レンダーフレームごとに 1 回、あるいは 1 回の非同期ロード処理中に 2 回発生します。VSync はこのパイプラインに影響を及ぼします。レンダースレッドが VSync を待機している間にアップロードを行えます。各フレーム 16 ms で実行していて、あるフレームが長く(例えば 17 ms に)なった場合、Vsync の待機時間は 15 ms になります。基本的に、フレームレートが高いほどアップロードタイムスライスの発生頻度が高くなります。
Q: AUP 経由で読み込まれるものは何ですか?
- 読み込み/書き込み有効でないテクスチャが AUP でアップロードされます。
- バージョン 2018.2 の時点では、テクスチャミップマップが AUP 経由でストリーミングされます。
- バージョン 2018.3 では、メッシュも(非圧縮で、かつ読み込み/書き込み有効でない場合のみ)AUP 経由でアップロードされます。
Q: 非常に大きなテクスチャをアップロードする場合など、アップロードされるデータに対して、リングバッファの大きさが足りない場合はどうなりますか?
- リングバッファより大きなアップロードコマンドは、リングバッファが完全に消費されるまで待機します。その後、必要な大きさに合わせてリングバッファが再度割り当てされます。アップロード完了後、リングバッファが元のサイズに再度割り当てられます。
Q: 同期ロード API(Resources.Load, AssetBundle.LoadAsset など)はどのように機能しますか?
- 同期ローディングコールは AUP を使用し、非同期アップロード処理が完了するまで実質的にメインスレッドをブロックします。使用される読み込み API の種類は関係ありません。
皆様のフィードバックをお待ちしています。本ページのコメント欄か Unity 2018.3 ベータ版のフォーラムから、ぜひご意見をお寄せください!