特殊な最適化
Apply advanced optimizations only after profiling confirms they address a specific performance problem.
読み終わるまでの所要時間 32 分最終更新 1ヶ月前
前のセクションでは、すべてのプロジェクトに適用可能な最適化について説明しましたが、このセクションでは、プロファイリングデータの収集前に 適用すべきでない 最適化について詳しく説明します。その理由は、この最適化が実装に労力を要し、パフォーマンスを優先してコードの明確さや保守性を劣化させる可能性や、一定のスケールで発生する問題しか解決できない可能性があるからです。
多次元配列とジャグ配列
この Stack Overflow の記事 で説明されている通り、基本的には、多次元配列の反復よりもジャグ配列の反復のほうが効率的です。これは、多次元配列が関数呼び出しを必要とするためです。
(注記)
-
これらは配列の配列であり、] ではなく
type[x,yとして宣言されます。type[x][y] -
これは、 ILSpy などのツールを使用して、多次元配列へのアクセスによって生成される IL の調査を行うことで発見可能です。
Unity 5.3 でプロファイリングした結果、 3 次元の 100x100x100 の配列に対する完全に連続した 100 回の反復が、次の時間をイールドしました(10 回のテストの平均結果)。
配列のタイプ | 合計時間(100 回の反復) |
|---|---|
| 1 次元配列 | 660 ms |
| ジャグ配列 | 730 ms |
| 多次元配列 | 3470 ms |
多次元配列へのアクセスのコストと 1 次元配列へのアクセスのコストの差は、追加的な関数呼び出しのコストを示しています。またジャグ配列へのアクセスのコストと 1 次元配列へのアクセスのコストの差は、非コンパクト メモリ構造のコストを示しています。
上記に示される通り、追加的な関数呼び出しのコストが、非コンパクト メモリ構造の使用に掛かるコストを大幅に上回っています。
パフォーマンスの影響を非常に受けやすい操作には、1 次元配列の使用が推奨されます。多次元の配列が必要な他のすべての場合は、ジャグ配列を使用します。多次元配列は使用しないでください。
パーティクル システムのプーリング
パーティクルシステムをプールする場合は、少なくとも 3500 バイトのメモリを消費することに気をつけてください。メモリ消費は、パーティクルシステムでアクティブなモジュールの数に基づいて増加します。このメモリは、パーティクルシステムを非アクティブにしても 解放されません。パーティクルシステムが破棄されたときにだけ解放されます。
Unity 5.3 以降は、パーティクルシステムの設定のほとんどはランタイムで操作可能になっています。多数の異なるパーティクル効果をプールする必要があるプロジェクトでは、パーティクルシステムの設定パラメーターをデータキャリアクラスまたは構造体に抽出したほうが効率的な場合があります。
これにより、パーティクル効果が必要なときは "汎用" パーティクル効果のプールから、必要なパーティクル効果のオブジェクトを提供できます。その後、そのオブジェクトに設定データを適用し、目的のグラフィカルな効果を得られます。
これは、(シーン内で使用されているパーティクルシステムの)考えられる全てのバリアントと設定のプールを試みるよりも格段に効率的ですが、技術的に相当な労力を要します。
Update マネージャー
Unityは内部的に、、、 など、コールバックの対象となるオブジェクトのリストを追跡します。これらは、リストの更新が一定時間内に行われるように、侵入的な連結リストとして管理されます。MonoBehaviour は、それぞれ有効または無効にされると、これらのリストに追加されるか、リストから削除されます。
UpdateFixedUpdateLateUpdateコールバックを必要とする MonoBehaviour にそれを単純に追加するだけであれば簡単ですが、この方法だとコールバックの数が増えるにつれて効率性が落ちます。マネージコードのコールバックをネイティブコードから呼び出すと、小さいながらも無視できないオーバーヘッドが発生します。これは、フレームごとにメソッドを多数呼び出す場合にはフレーム時間の劣化を引き起こし、 MonoBehaviour を多数含むプレハブのインスタンス化を行う際にはインスタンス化時間の劣化を引き起こします (ノート: このインスタンス化コストは、プレハブ内の各コンポーネントに Awake や OnEnable コールバックを呼び出す際のパフォーマンス オーバーヘッドに起因するものです。)
毎フレームのコールバックを持つ MonoBehaviour の数が数百、数千にのぼる場合は、これらのコールバックを取り除き、代わりに MonoBehaviour (あるいは標準の C# オブジェクト) をグローバルマネージャーシングルトンに添付すると便利です。こうすることで、グローバルマネージャーシングルトンから、 や などのコールバックを対象のオブジェクトに送ることができます。これには、コードが何もしない場合にコールバックから効率的にサブスクライブを解除できるので、毎フレーム呼び出されなければならない関数の数を削減できるという別の利点もあります。
UpdateLateUpdate最も効果的な節約方法は、稀にしか実行されないコールバックを取り除くことです。以下の擬似コードを検討します。
void Update() { if(!someVeryRareCondition) { return; }// … some operation …}
上記のような Update コールバックを持つ MonoBehaviour が多数ある場合、Update コールバックの実行に掛かる時間のうち相当な割合が、 MonoBehaviour を実行して直ちに終了するネイティブコードドメインとマネージコードドメインの切り替えに費やされます。代わりに、これらのクラスが、 が true の間だけグローバルアップデートマネージャーにサブスクライブして、その後サブスクライブを解除するようにした場合、コードドメインの切り替えに掛かる時間と、稀な状態の評価に掛かる時間の両方が節約できます。
someVeryRareConditionUpdate マネージャー内における C# デリゲートの使用
これらのコールバックの実装には、単純な C# デリゲートを使用したいところです。ただし、C# デリゲートの実装は、サブスクリプションとサブスクリプション解除の頻度が低く、コールバックの数も少ない場合に最適化されています。C# デリゲートは、コールバックが追加または削除されるたびに、コールバックリストの完全な詳細コピーを実行します。コールバックのリストが大きい場合、または 1 つのフレーム内で多数のコールバックがサブスクライブ/サブスクライブ解除される場合、内部 メソッドのパフォーマンスでスパイクが発生します。
Delegate.Combine追加や削除が頻繁に行われる場合は、デリゲートではなく素早い insert ・ remove 用に設計されたデータ構造の使用をご検討ください。
読み込みスレッドの制御
Unity では、データロードに使用するバックグラウンドスレッドの優先度を開発者が制御できます。これは、バックグラウンドでアセットバンドルをディスクにストリーミングしたい場合には特に重要です。
メインスレッドとグラフィックススレッドの優先順位はどちらも です。優先順位が高いスレッドはすべて、メインスレッドとグラフィックススレッドをプリエンプトしてフレームレートの一時的な中断を引き起こしますが、優先順位の低いスレッドは一時的な中断を引き起こしません。スレッドがメインスレッドと同等の優先順位を持つ場合、CPU はスレッドに同じ時間を割り当てようとします。そのため、アセットバンドルの解凍など、複数のバックグラウンドスレッドが重い操作を実行している場合は、通常、フレームレートのスタッターが発生します。
ThreadPriority.Normal現段階で、優先度の制御は 3 箇所で行われます。
まず、 や など、アセットロードの呼び出しのデフォルト優先度は、 Application.backgroundLoadingPriority 設定から取得します。すでに説明したように、この呼び出しでは、メインスレッドがアセットの統合に費やす時間も制限されます (ノート: ほとんどのタイプの Unity アセットは、メインスレッドに "統合" する必要があります。統合中にアセットの初期化が完了し、特定のスレッドセーフ操作が実行されます。これには、Awake コールバックなどのスクリプトコールバックの呼び出しが含まれます。詳細は “リソース管理” ガイドを参照してください。アセットのロードがフレーム時間に与える影響を制限できます。
Resources.LoadAsyncAssetBundle.LoadAssetAsync次に、非同期の各アセットロード処理と、各 UnityWebRequest リクエストが、処理のモニタリングと管理のために オブジェクトを返します。この オブジェクトは、個々の操作の優先順位を調整するのに使用できる priority プロパティを公開します。
AsyncOperationAsyncOperation最後に、 の呼び出しなどから返された WWW オブジェクトは threadPriority プロパティを公開します。WWW オブジェクトは、自動的に 設定をデフォルト値として使用しないことに注意してください。WWW オブジェクトは常に がデフォルトです。
WWW.LoadFromCacheOrDownloadApplication.backgroundLoadingPriorityThreadPriority.Normalデータの解凍とロードに使用される内部的なシステムは、これらの API 同士で異なります。 と は Unity の内部的な PreloadManager システムによって操作されます。このシステムはロードスレッドを独自に管理し、速度制限も独自に実行します。 は、専用のスレッドプールを使用します。 は、リクエストがあるたびに完全に新規のスレッドを生成します。
Resources.LoadAsyncAssetBundle.LoadAssetAsyncUnityWebRequestWWW他のすべてのロードメカニズムにはビルトインのキューイングシステムがありますが、WWW には備わっていません。多数の圧縮アセットバンドルで を呼び出すと、同等の数のスレッドが生成され、メインスレッドと CPU 時間で競合します。その結果、フレームレートのスタッターが発生しやすくなります。
WWW.LoadFromCacheOrDownloadしたがって、WWW を使用してアセットバンドルのロードと解凍を行う場合は、作成された各 WWW オブジェクトの に適切な値を設定することが推奨されます。
threadPriority大量のオブジェクトの動きと CullingGroups
Transform の操作に関するセクションで触れた通り、大きな Transform 階層を動かす際は、変更メッセージの伝播による CPU への負荷が比較的高くなります。しかし、実際の開発環境では、ゲームオブジェクトが少数になるように階層を縮小することができない場合も往々にしてあります。
また、開発においては、ゲーム世界の信憑性を保つのに十分な動作を実行しつつ、ユーザーに気付かれないような動作は取り除くのが理想的です。例えば、シーンにキャラクターが多数存在する場合は、メッシュスキニングやアニメーションに基づく Transform の動きは画面上にいるキャラクラーにのみ実行するのが賢明です。キャラクターが画面外にいるにも関わらず、単なるビジュアル要素のシミュレーションの計算に無駄な CPU 時間を費やす必要はありません。
これらの問題は両方とも、 Unity 5.1 から提供されている API である CullingGroups で適切に対処できます。
シーン内で多数のゲームオブジェクトを直接操作するのではなく、特定の CullingGroup 内にある BoundingSphere のグループの Vector3 パラメーターを操作するようシステムを変更します。各 BoundingSphere がそれぞれ 1 つのゲーム論理エンティティのワールド空間位置に対する権限を持つリポジトリとして機能し、そのエンティティが CullingGroup のメインカメラの錐台内 (または近く) に来た時に、コールバックを受け取ります。その後、このコールバックを使用してコードやコンポーネント (Animator など) の有効化/無効化を行い、エンティティの表示中にだけ実行が必要な動作を管理することができます。
メソッドコールのオーバーヘッドの削減
C# の文字列ライブラリは、単純なライブラリコードに追加的なメソッドコールを加える場合のコストに関する非常に有益なケーススタディを提供します。ビルトインの文字列 API の と に関するセクションで、不適切なロケール強制が抑制された場合においても、ビルトインのメソッドよりも手動でコードを置き換えたほうが 10 から 100 倍速いことが言及されています。
String.StartsWithString.EndsWithこのようにパフォーマンスに差が出る主な理由は単純で、タイトな内部ループに追加的なメソッドコールを加えるコストによるものです。呼び出される各メソッドは、そのメソッドのアドレスをメモリ内で特定し、別のフレームをスタックにプッシュしなければなりません。この両方の処理にそれぞれコストが掛かりますが、これはほとんどのコードにおいては無視しても問題ない小さなコストです。
ただし、小さなメソッドをタイトなループ内で実行している場合は、メソッドコールの追加によるオーバーヘッドが大きくなることもあり、それが大部分を占めるまでになる可能性もあります。
以下の二つのメソッドをご覧ください。
例 1
int Accum { get; set; }Accum = 0;for(int i = 0; i < myList.Count; i++) { Accum += myList[i];}
例 2
int accum = 0;int len = myList.Count;for(int i = 0; i < len; i++) { accum += myList[i];}
これらのメソッドは両方とも、C# ジェネリック の整数の合計値を算出します。1 つ目の例は、自動生成されたプロパティを使用してデータ値を保持するという点で、より "新しい形の C#" であると言えます。
List<int>表面的にはこれら二つのコードは同等であるように見受けられますが、メソッドコールの観点からコードを分析すると違いが分かります。
例 1
int Accum { get; set; }Accum = 0;for(int i = 0; i < myList.Count; // call to List::getCount i++) { Accum // call to set_Accum+= // call to get_AccummyList[i]; // call to List::get_Value}
毎回のループ実行時には 4 つのメソッドコールがあります。
- が
myList.CountプロパティのCountメソッドを呼び出します。get - プロパティの
Accumメソッドとgetメソッドを呼び出す必要があります。set - で
getの現在の値を検索し、追加操作に渡せるようにします。Accum - で 追加操作の結果を
setに割り当てます。Accum - 演算子 [] はリストの get_Value メソッドを呼び出し、リストの特定のインデックスにあるアイテムの値を取得します。
例 2
int accum = 0;int len = myList.Count;for(int i = 0; i < len; i++) { accum += myList[i]; // call to List::get_Value}
例 2 では、 の呼び出しはそのままですが、他のすべてのメソッドは削除されているか、ループのイテレーションごとに 1 回実行されなくなっています。
get_Value-
はこの時点ではプロパティではなくプリミティブ値となっているため、その値の設定や取得のためにメソッドコールが作成される必要はありません。
accum -
はループの実行中には変化しないことが想定されているため、そのアクセスはループの条件ステートメントの外に移動されており、したがって、ループのイテレーションの開始時に毎回実行されなくなっています。
myList.Count
2 つのバージョンの実行時間を確認してみると、この特定のコードスニペットからメソッドコールのオーバーヘッドの 75% を取り除くことの真の利点が理解できます。最近のデスクトップマシンで 100,000 回実行された場合、以下のようになります。
- 例 1 は実行に 324 ミリ秒掛かります。
- 例 2 は実行に 128 ミリ秒掛かります。
ここで主な問題は、Unity がメソッドのインライン化をほとんど行わないことです。IL2CPP 下であっても、多くのメソッドは現在、適切なインライン化を行いません。これは特にプロパティに関して言えることです。さらに、virtual および interface メソッドのインライン化は一切行えません。
このため、 C# のソースコードで宣言されたメソッドは、最終版のバイナリーアプリケーション内にメソッドコールを生成する場合が非常に多くなります。
自明なプロパティ
Unity では、開発者の便宜のため、各種データ型に "単純な" 定数を多数提供しています。ただし上記を踏まえ、これらの定数は基本的に定数値を返すプロパティとして実装されています。
Vector3.zero のプロパティのボディは以下のようになります。
get { return new Vector3(0,0,0); }
Quaternion.identity はこれに非常に類似しています。
get { return new Quaternion(0,0,0,1); }
通常、これらのプロパティへのアクセスのコストは、その周りにある実際のコードと比較して非常に小さいものですが、これらが 1 フレームに何千回(またはそれ以上)の頻度で実行される場合は若干の違いが出ます。
単純なプリミティブ型には、代わりに 値を使用してください。 値はコンパイル時にインライン化され、 変数への参照はその値に置き換えられます。
constConstconst注意 変数への参照はすべてその値に置き換えられるため、長い文字列やその他の大きなデータ型の を宣言することは推奨されません。これにより、最終的な命令コード内のすべての複製されたデータが原因で、最終的なバイナリサイズが不必要に膨れ上がります。
constconstconststatic readonlystatic readonly自明なメソッド
自明なメソッドに関してはより複雑です。機能の宣言を一度行い、それを他の場所で再使用できれば非常に便利です。しかし、タイトな内部ループ内では本来の理想的なコーディング方法を逸脱し、一部のコードを "手動でインライン化" しなければならない場合もあります。
一部のメソッドは完全になくすことができます。、、 の使用を検討してください。これらは非常に自明な操作を実行するものであり、単純な割り当てステートメントと置き換えることができます。
Quaternion.SetTransform.TranslateVector3.Scaleより複雑なメソッドの場合は、手動によるインライン化のコストと、より効率的なコードの管理に掛かる長期的コストを、プロファイリング分析で比較してください。