Unity を検索

最適化の最前線

2019年11月14日 カテゴリ: Engine & platform | 13 分 で読めます
取り上げているトピック
シェア

Is this article helpful for you?

Thank you for your feedback!

私は EMEA Consulting & Development チームのデベロッパー窓口担当エンジニアとして、Unity の大口顧客を訪問し、プロジェクトのパフォーマンス関連の問題解決をサポートすることにほとんどの時間を費やしていました。この記事ではその進め方を紹介しています。ここで学んだ知識や手法をご自身のプロジェクトにお役立てください。

Unite Copenhagen 2019 にて、私は「Tales From the Optimization Trenches」(最適化の最前線)というセッションを担当しました。意図としては、ベストプラクティスガイドのアドバイスにはすでに目を通しているものの、プロファイリングツールや分析ツールを使用してパフォーマンスの問題を独自に診断して解決するために求められる、実践的な知識が不足している Unity の中級ユーザーを対象に、サポートを提供することでした。

この講演では次の内容について説明しています。

  • デベロッパー窓口担当エンジニアの役割と、主に担っているプロジェクトレビューを提供する業務の概要。
  • 最適化とプロファイリングの基本。
  • CPU、GPU、メモリフットプリントの最適化について。3 つのセクションではそれぞれ実際にあった問題への取り組み事例を 2 つずつ挙げて、それらを解決するために利用したツールやテクニックを紹介します。
  • 一連の最適化に関するルール全般。
  • Q&A。

下のビデオをご覧ください。関連スライドはこちら

すべてを網羅するには時間が足りませんでしたが、Unite の参加者とのフォローアップディスカッションは非常に有意義でした。その模様はビデオには含まれていないので、その内容を皆さんと共有するためにこのブログ記事を執筆しました。それでも、まずはビデオを視聴することを強くお勧めします。

このコンテンツはサードパーティのプロバイダーによってホストされており、Targeting Cookiesを使用することに同意しない限り動画の視聴が許可されません。これらのプロバイダーの動画の視聴を希望する場合は、Targeting Cookiesのクッキーの設定をオンにしてください。

プロジェクトレビューについて

プロジェクトレビューは私たちの業務の中核を成します。私たちは顧客のオフィスを訪問し、通常は丸 2 日間かけてプロジェクトの把握に努め、顧客の要件や顧客が下した設計に関する意思決定についていくつかの質問を投げ、各種プロファイリングツールを使用してパフォーマンスのボトルネックを検出します。ビルド時間が少ないアーキテクチャの優れたプロジェクト(モジュラー型シーン、アセットバンドルの多用など)を実現するために、私たちは現場にいる間に実際に変更を加え、新しい問題を明らかにするために再プロファイリングを行います。これがビルド時間を最適化することが重要である理由です。そうすることで、より頻繁なイテレーションが可能になります。これは、開発中に使用するハードウェアと対象ハードウェアが大きく異なる(モバイルデバイスとゲームコンソールなど)プロジェクトではさらに重要です。

幸運なことに、私たちの顧客は多岐にわたっており、それらの顧客が取り組んでいるプロジェクトの種類は幅広いプラットフォームや要件を網羅しているため、プロジェクトレビューは決して同じになることがありません。訪問中に解決できない複雑な問題については、できるだけ多くの情報を集め、Unity のオフィスに戻ってさらに調査を行い、必要に応じて当社の研究開発部門のその分野に特化した開発者に確認します。納品物は顧客のニーズによって変わりますが、通常は調査結果をレポートの形式でまとめ、推奨事項を提案します。どこに注力するかは、常に顧客に最大の価値を届けることを目標に判断するようにしています。

私たちは Unity のソースコードにアクセスできますが、プロジェクトレビューを実施するときは、顧客と同じ立ち位置に身を置くようにしています。つまり、顧客のプロジェクトを一般的に利用可能なプロファイリングツールやベストプラクティスを活用して最適化します。パフォーマンスの問題の原因を突き止めるために実際に内部の仕組みまで探る必要がある場合は、発見した新しい知識をすべてのユーザーが利用できるようにして波及させるために、後でドキュメントを更新するよう最善を尽くします。

CPU バウンドか GPU バウンドか

プレゼンテーション中に言及したように、プロジェクトの最適化に着手する前に、実際のボトルネックを探し出す必要があります。その方法の 1 つに、Unity プロファイラーを使用して CPU 使用率の内訳を調べる方法があります。下の画像に示すように、フレーム時間のほとんどが「レンダリング」に使われている場合、CPU バウンドであるか GPU バウンドであるかを判断する必要があります。

レンダリングとは、CPU と GPU の両方が一体となって実行されるプロセスです。このプロセスの包括的な説明についてはこの記事では取り上げませんが、簡潔に言うと、次の手順で構成されるシーンのレンダリングのことです。

  1. マテリアルを共有する各オブジェクトのグループについて、次のように処理されます。
    1. CPU が一連のコマンドを GPU に送信して、その内部状態(シェーダー、バウンドテクスチャー、頂点形式など)を設定します。この手順は、「セットパス」コールとしても知られています。
    2. CPU がジオメトリのバッチを GPU に送信し、1.A で設定した状態を使用してレンダリングされるようにします。この手順は「ドローコール」とも呼ばれ、非常にコストがかかります。
    3. 同じ種類のマテリアルの下にレンダリングする必要があるジオメトリが他にもある場合は、手順 1.B に進みます。

繰り返しになりますが、上のアルゴリズムにはいくつかの細かい点や注意点があるものの、主な教訓として、レンダリングとは CPU と GPU で合わせて実行されるアクティビティであるということです。下のスクリーンショットで示すように、Xcode などの一部のツールでは、両方のリソースによって実際に消費される時間に関する詳細情報が提供されます。

また、この種類の情報は Unity プロファイラーでも見つかりますが、GPU の指標はグラフィックカードやそのドライバーによって提供されるサポートに依存するため、常に利用できるわけではないことに注意してください。

プロファイリングツールを使用して CPU と GPU の時間を取得できない場合は、Unity プロファイラーで常に任意のフレームを調べることができます。Gfx.WaitForPresent が呼び出されたときに「call is taking a considerable amount of time(呼び出しにかなり時間がかかっています)」と表示された場合、GPU がすべてのレンダリングコマンドの処理を終了するのを CPU が待っていることを意味します。これを GPU バウンドと呼びます(他のマーカー(WaitForTargetFPSGfx.PresentFrame など)の裏側にある意味について理解するには、マニュアルのこのページを参照してください)。

次のような、さまざまな要素が GPU ワークロードに影響を与える可能性があります。

  • フィルレート:アプリケーションの特定のフレームで大量のピクセルが複数回カラーリングされる(この処理は「オーバードロー」と呼ばれます)。
  • メモリ帯域幅:アプリケーションから GPU に大量のテクスチャーデータが送信される。これは、テクスチャーの数を減らす(アトラス化を介してなど)、サイズを小さくする、可能な場合は圧縮された形式に設定することで軽減できます。
  • 頂点処理:アプリケーションから GPU に送信されるジオメトリが多すぎる。このシナリオは、Unite のプレゼンテーションにて例の 1 つとして紹介しています。

逆に、CPU バウンドの場合、CPU 時間に影響を及ぼす要素は数多く存在するため(物理演算、ゲームプレイのコードなど)、プロファイラーを確認してください。プロファイラーでレンダリングに長時間かかっていることが示されている場合、CPU が大量のコマンドを GPU に送信しているためビジー状態にある可能性があります。これは、状態変更(「SetPass」コール)とバッチの数の両方を減らすことで最適化できます。Fixing Performance Problems(パフォーマンスの問題を修正する)のチュートリアルで、このテーマについて詳しく解説しています。

ケーススタディ:データ読み込み時に CPU でスパイクが発生する

顧客のプロジェクトでよく見られるパフォーマンスの問題は、アプリケーションの起動フェーズ中や新しいレベルへの移行中に発生する、一時的なパフォーマンスの低下(コマ落ち)です。このようなコマ落ちは、Unity プロファイラーでスパイクとして記録されます。

また、それらは通常、演算処理のコストが高すぎることと、メモリのアロケーションが大きすぎることの両方が原因で発生します。この例では、下のスクリーンショットに示すように、CPU のスパイクにより 10 秒程度の停止状態と、3.8 GB のマネージアロケーションが発生しています。

これらのスパイクは、主に 2 つの理由で望ましくないものです。1 つ目の理由は、スパイクによる停止時間が非常に長くなりアプリケーションのフローが中断されてしまうことです。CPU のスパイクによる停止状態を「隠す」方法として、ローディング画面を使用する方法があります。ただし、この解決策は画面にアニメーション化された要素を表示する必要がある場合は機能しません。アニメーションがローディング処理中に停止してしまうからです。スパイクが望ましくない 2 つ目の理由は、大量のアロケーションによりマネージヒープのサイズが永続的に増えてしまうことです。Unity の自動メモリ管理システムは、参照されていないメモリが後続のアロケーションで再利用されるように動作しますが、マネージヒープの全体的なサイズは増え続ける一方で、減ることはありません。これは、「コンパクションされないガベージコレクション」と呼ばれます。詳細については、Unity ドキュメントのこのエントリと、Unity Learn ウェブサイトのこの記事を参照してください。

スパイクは通常、複数の要素の組み合わせによって発生します。フィールドに表示されている情報によると、アプリケーションでは最適化されていない形式でデータが格納されており(JSON や XML)、かつそれらのコンテンツを処理するためにパーサーで大量のメモリを割り当てる必要があることが原因です。多くの場合、それらのアロケーションと、データ(およびそれらに関連するメモリアロケーション)の運用に必要とされる集約型の演算が組み合わさることが、主な原因です。

これらの問題を軽減するために、私たちは通常、顧客に「予算型タイムマネージャー(budgeted time manager)」システムを実装することをお勧めしています。このシステムでは、オブジェクトがフレーム毎の上限に収まるようインスタンス化および初期化され、バイナリ形式のサポートが追加されています。「予算型タイムマネージャー」では、コストが複数のフレームに分散され、バイナリ形式をサポートすることでアロケーションのサイズを最小限に収めることを支援します。

すべてのデータを単一のメソッドに読み込む代わりに「予算型タイムマネージャー」を採用するという考え方は、通常のガベージコレクターの代わりにインクリメンタルガベージコレクターを使用する差に類似しています。通常のガベージコレクターは、リストの全マネージオブジェクトが処理されるまでフレームが停止状態になりますが、インクリメンタルガベージコレクターは複数のフレームにわたって作業を分散します。

バイナリ形式は、その性質から、開発中での取り扱いは一般的に注意を要します。そこで、顧客にはテキスト形式のサポートを完全に削除しないことをお勧めしています。代わりに、テキスト形式とバイナリ形式を両方サポートし、開発中に実行するのかアプリケーションのバージョンをリリースするのかによって使い分けてください。

ガベージコレクションに関するいくつかのコメント

「テンポの速いゲームにおける GC スパイク」の例で、私たちは顧客にインクリメンタルガベージコレクターを有効にし、できるだけフレーム時間を減らすことで、全フレームの最後にアルゴリズムが機能するゆとりを十分に用意するよう助言しました。プレゼンテーション中に十分に言及できなかった点として、インクリメンタルガベージコレクターは、メモリのマネージアロケーションの量とサイズを最小限にすることについて、その基準を甘くするための口実にはなりません。通常のガベージコレクションと比較したこのツールの主な利点として、マネージオブジェクトのプール全体が処理されるまでプールを停止する代わりに、ワークロードを複数のフレームにわたって拡散することが挙げられます。これは、安定したフレームレートを確保するために特に重要です。

ガベージコレクターは、次のようにスクリプトで GarbageCollector.GCMode 静的フィールドの値を GarbageCollector.Mode.Disabled に設定することで、実際に無効化できます。

GarbageCollector.GCMode = GarbageCollector.Mode.Disabled;

この手法は、ガベージコレクションアルゴリズムに関連する処理コストをかけたくないシナリオで有用です。ただし、それを行うには、ガベージコレクターが無効になっているときにアロケーションが発生していないことを確認する必要があることに注意してください。プレゼンテーション中に言及したように、メモリの使用量が一定のしきい値を超えると、オペレーティングシステムでアプリケーションの使用が容易に停止されてしまうからです。これは、Android や iOS などのモバイルプラットフォームで特に顕著です。

ケーススタディ:権威サーバーを使用する FPS

数か月前、私たちはサーバーが headless モードで実行されている、権威サーバーアーキテクチャを特徴とする、多人数参加型の一人称視点シューティングゲームのプロジェクトレビューを実施しました。Unity Memory Profiler を使用してメモリのキャプチャを実行することで、メッシュ、ライトプローブ、オーディオクリップ、メッシュレンダラー、その他 headless サーバーには実際には必要でなかったさまざまな種類のオブジェクトに何百 MB も割り当てられていることがわかりました。

この余分なメモリフットプリントは、サーバーでの単一のマルチプレイヤーセッションの実行には影響しなかった一方、スケーリングに影響を及ぼすことは明白でした。より具体的には、指定のサーバーでアクティブなインスタンスの数を増やすには、メモリを大幅に増やす必要がありました。

このシナリオでは、顧客にゲームレベルの全シーンを 2 つに分割し、別個のアセットバンドルに格納するよう助言しました。最初のエンティティは「論理シーン」で、headless サーバーで求められるすべての情報が含まれている一方で、2 つ目のエンティティは「視覚シーン」で、クライアントによって独占的に使用されるすべての情報が含まれています。

この分割により、ワークフローの面でいくつかの問題が発生する可能性があります。より具体的には、アーティストやレベルデザイナーが単一のシーンで作業できなくなります。コンテンツクリエイターのワークフローを妨害しないよう、顧客にはその部分はそのままにすることを推奨し、ビルドプロセスの一環として、シーンを「論理シーン」と「視覚シーン」に分割するためのサポートを追加しました。

ディーププロファイリングとプロファイラーマーカー

以前にお話したように、アプリケーションのコアループではフレームごとのアロケーションをほぼゼロにすることを目指す必要があります。こうすることで、ガベージコレクションアルゴリズムによって引き起こされるオーバーヘッドが大幅に減少します。Unity プロファイラーはこのジョブにとって最適なツールですが、報告されたコールスタックにおけるデフォルトレベルの深度は、エンジンのネイティブコードからアプリケーションのスクリプトコードへの呼び出しの最初のコールスタックの深度までしか到達しません(例:MonoBehaviour.Start()MonoBehaviour.Update()、および類似のメソッド)。実際のところ、これは、使用するスクリプトが他のスクリプトからメソッドを(通常そうしているように)呼び出している場合、そのマネージアロケーションが実行されている正確な場所を特定することが簡単ではないことを意味します。

この問題に対処する方法の 1 つとして、スクリプトにプロファイラーマーカーを明示的に追加する方法があります。このようにすると、プロファイリングプロセス中に追加の情報を記録し、アロケーションの出所を絞り込むのに役立ちます。

2 つ目の方法は、ディーププロファイリングを有効にする方法です。これを行う具体的な手順は、Unity Learn Web サイトのこちらの記事にあります。ディーププロファイリングによって大量のオーバーヘッドが追加され、アプリケーションの実行速度が大幅に低下するため、レポート作成のタイミングは正確ではなくなることに注意してください。お勧めしている方法は、次のとおりです。まずディーププロファイリングを無効にした状態でプロファイリングセッションを実行し、どのシナリオで不要なマネージアロケーションが発生しているかをメモします。報告されたコールスタックの詳細が十分でなく、アロケーションの発生元を突き止められない場合は、アロケーションの発生元を探すために、ディーププロファイリングを有効にした状態で 2 回目のセッションを実行します。

Unity 2019.3 より前では、ディーププロファイリングは Mono スクリプティングバックエンドを使用しているときにのみ利用できました。この制約は Unity 2019.3 のベータサイクルで撤廃され、Mono と IL2CPP のバックエンドの両方に対応しました。リリースノートより:

プロファイラー:Mono および IL2CPP のプレイヤーにディーププロファイラーのサポートを追加。
プロファイラー:プレイヤーにディーププロファイリングのサポートのビルドオプションを追加。ディーププロファイリングでプレイヤーをビルドするときに、C# コードインストルメンテーションを動的に有効化および無効化できます。
プロファイラー:プレイヤーにマネージアロケーションコールスタックサポートを追加。コールスタックのコレクションを有効にすると、GC.Alloc サンプルに C# コードのコールスタックが含まれます。

IL2CPP バックエンドでディーププロファイリングを使用できるようになりましたが、これは、IL2CPP のみをサポートするプラットフォーム(iOS など)上で、開発者がディーププロファイルのキャプチャを実行できるようになったことを意味します。さらに、プレイヤーにマネージアロケーションコールスタックのサポートが追加されたことで、開発者がディーププロファイリングに頼ることなく、アロケーションの発生元を探すことの助けになるはずです。

次のステップ

パフォーマンスの最適化は、幅広いスキルが要求される大きなトピックです。基盤となるハードウェアの動作と、その制約についての理解などのスキルが要求されます。Unity によって提供されるさまざまなクラスやコンポーネント、アルゴリズムやデータ構造、プロファイリングツールの使用方法についての理解のほか、設計要件も満たす効率的なソリューションを見つけるには創造性も必要とされます。

私たちは皆さんが Unity のアプリケーションを使用して最大限のパフォーマンスを発揮できるよう支援したいと考えています。他にも取り上げてほしい最適化に関わるトピックがありましたら、コメントセクションを通じてお知らせください。

2019年11月14日 カテゴリ: Engine & platform | 13 分 で読めます

Is this article helpful for you?

Thank you for your feedback!

取り上げているトピック