Version: 2023.2
言語: 日本語
一般的な最適化
アセットローディングメトリクス

特殊な最適化

前セクションまでは、全てのプロジェクトに応用可能な最適化手法について説明させていただきました。本セクションでは、プロファイリングデータの収集前には行うべきではない最適化方法についてご説明します。この理由は様々で、例えば導入に甚大な労力が掛かる場合や、コードの明瞭さや保全性が失われる場合、パフォーマンスが犠牲になる場合、あるいは、解決される問題が、特定の規模においてのみ発生する場合などが考えられます。

多次元配列とジャグ配列

こちらのスタック・オーバーフローに関する記事 で説明されている通り、基本的には、多次元配列の反復よりもジャグ配列の反復のほうが効率的です。これは、多次元配列が関数呼び出しを必要とするためです。

(注記)

  • これらは配列の配列であり、 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 は内部的に、そのコールバックを受けるオブジェクトのリストを随時把握しています。例えば UpdateFixedUpdateLateUpdate などです。これらは、リストの更新が一定の間隔で確実に行われるようにするために、イントルーシブなリンクのリストとして管理されます。 MonoBehaviour は有効化されるとリストに追加され、無効化されるとリストから削除されます。

コールバックを必要とする MonoBehaviour にそれを単純に追加するだけであれば簡単ですが、この方法だとコールバックの数が増えるに従って効率性が落ちます。マネージドコードのコールバックをネイティブコードから呼び出すと、小さいながらも無視できないオーバーヘッドが発生します。これは、毎フレーム実行されるメソッドを多数呼び出す場合にはフレーム時間の劣化を引き起こし、 MonoBehaviour を多数含むプレハブのインスタンス化を行う際にはインスタンス化時間の劣化を引き起こします([注] このインスタンス化コストは、プレハブ内の各コンポーネントに Awake や OnEnable コールバックを呼び出す際のパフォーマンス オーバーヘッドに起因するものです。)

毎フレームのコールバックを持つ MonoBehaviour の数が数百、数千にのぼる場合は、これらのコールバックを取り除き、代わりに MonoBehaviours(あるいは標準の C# オブジェクト)をグローバルマネージャー シングルトンに添付すると有利です。その後グローバルマネージャー シングルトンから、UpdateLateUpdate などのコールバックを関連オブジェクトに送ることができます。このようにすると、(本来なら co-op となる)コードがコールバックから効率的にアンサブスクライブできるので、毎フレーム呼び出されなければならない関数の数を削減できるという付加的な利点もあります。

最も効果的な節約方法は、稀にしか実行されないコールバックをなくすことです。以下の擬似コードをご覧ください。

void Update() {
    if(!someVeryRareCondition) { return; }
// … いくつかの操作 …
}

上記のような Update コールバックを持つ MonoBehaviour が多数ある場合、Update コールバックの実行に掛かる時間のうち相当な割合が、 MonoBehaviour 実行(その後直ちに終了する)のための、ネイティブコード ドメインとマネージドコード ドメインの切り替えに費やされています。代わりに、もしもこれらのクラスが、 someVeryRareCondition が true の間だけグローバル アップデート マネージャーにサブスクライブし、その後アンサブスクライブすれば、コードドメインの切り替えに掛かる時間と、 Rare Condition の評価に掛かる時間の両方が節約されます。

Update マネージャー内における C# デリゲートの使用

これらのコールバックの実装には通常の C# デリゲートを使用したくなるところですが、 C# のデリゲートの実装は、サブスクライブとアンサブスクライブの頻度が低い場合、かつコールバックが少数の場合に合わせて最適化されています。 C# デリゲートは、コールバックが 1 つ追加または削除される度に、コールバック リストの完全なディープコピーを行います。大きなコールバックのリストや頻繁なコールバックのサブスクライブ・アンサブスクライブが 1 つのフレーム内にあると、内部の Delegate.Combine メソッドにパフォーマンスのスパイクが発生します。

追加や削除が頻繁に行われる場合は、デリゲートではなく素早い insert ・ remove 用に設計されたデータ構造の使用をご検討ください。

読み込みスレッドの制御

Unity では、データの読み込みに使用されるバックグラウンドのスレッドの優先度をディベロッパーが操作できるようになっています。これは、バックグラウンドでアセットバンドルをディスクにストリーミングしたい場合には特に重要です。

メインスレッドとグラフィックスレッドの優先度は両方とも ThreadPriority.Normalです。これより優先度が高いスレッドは全て、メインスレッドやグラフィックスレッドを一時停止させ、フレームレートのカクつきを発生させます。これより優先度の低いスレッドの場合はこれは起こりません。メインスレッドと優先度が同等なスレッドの場合は、 CPU が両スレッドに同等の時間の分配を試みます。これは通常、アセットバンドル解凍などの高負荷処理がバックグラウンドで複数のスレッドによって行われている場合に、フレームレートのカクつきを発生させます。

現段階で、優先度の制御は 3 箇所で行われます。

[1 つ目] Resources.LoadAsyncAssetBundle.LoadAssetAsync など、アセット読み込みコールのデフォルト優先度は、 Application.backgroundLoadingPriority の設定から取得されます。ここに言及のある通り、このコールは、アセット読み込みがフレーム時間に及ぼす影響を抑えるために、メインスレッドがアセット統合に費やす時間も制限します。([注] ほとんどの種類の Unity アセットはメインスレッドに「統合」される必要があります。統合中には、アセットの初期化が完了され、特定のスレッドセーフ操作が実行されます。これにはスクリプト コールバックの呼び出し{ Awake コールバックなど}が含まれます。詳細は、リソース管理に関するガイドをご覧ください。)

[2 つ目]処理のモニタリングと管理のため、非同期のアセット読み込み処理のそれぞれと UnityWebRequest リクエストのそれぞれが、 AsyncOperation オブジェクトを戻します。この AsyncOperation オブジェクトは、個々の操作の優先度の調整に使用できる priority プロパティをアクセス可能にします。

[3 つ目] WWW オブジェクト( WWW.LoadFromCacheOrDownload の呼び出しから戻されたものなど)は threadPriority プロパティをアクセス可能にします。 WWW オブジェクトのデフォルト値には自動的に Application.backgroundLoadingPriority 設定が使用される訳ではありません。 WWW オブジェクトは常に ThreadPriority.Normal をデフォルトにします。

データの解凍と読み込みに使用される内部的システムは、これら API 同士で異なります。 Resources.LoadAsyncAssetBundle.LoadAssetAsync は Unity の内部的な PreloadManager システムによって操作されます。このシステムは読み込みスレッドを独自に管理し、頻度制限も独自に実行します。 UnityWebRequest は、専用のスレッドプールを使用します。 WWW は、リクエストがある度に完全に新規のスレッドを生成します。

この他の読み込み方式は全てビルトインの待ち行列システムを持っていますが、 WWW にはそれがありません。圧縮された大量のアセットバンドルに WWW.LoadFromCacheOrDownload を呼び出すと、相当数のスレッドが生成されます。そうしたスレッドは CPU 時間をメインスレッドと取り合います。これによりフレームレートのカクつきが発生しやすくなります。

したがって、 WWW を使用してアセットバンドルの読み込みと解凍を行う場合は、作成された各 WWW オブジェクトの threadPriority に適切な値を設定することが推奨されます。

大量のオブジェクトの動きと CullingGroups

Transform の操作に関するセクションで触れた通り、大きな Transform のヒエラルキーを動かす際は、変更メッセージの伝播による CPU への負荷が比較的高くなります。しかし、実際の開発環境では、ゲームオブジェクトが少数になるようにヒエラルキーを分解するのが不可能な場合も往々にしてあります。

また、開発においては、ゲーム世界の信憑性を保つのに十分な挙動を実行しつつ、ユーザーに気付かれないような挙動は取り除くのが理想的です。例えば、シーンにキャラクターが多数存在する場合は、メッシュ スキニングやアニメーションに基づく Transform の動きは画面上にいるキャラクラーに関してのみ実行するのが賢明です。画面外にいるキャラクターのシミュレーションの純粋な視覚的要素の計算に、無駄な CPU を使用する必要はありません。

これらの問題は両方とも、 Unity 5.1 から提供されている API CullingGroups によって綺麗に解決できます。

シーン内で多数のゲームオブジェクトを直接操作するのではなく、特定の CullingGroup 内にある一式の BoundingSphere(s) の Vector3 パラメーターを操作できるように、システムを操作してください。それぞれの BoundingSphere が、ひとつのゲーム論理要素のワールド空間位置の権威レポジトリとして機能し、その要素が CullingGroup のメインカメラの錘台内(または近く)に来た時に、コールバックを受け取ります。その後、このコールバックを使用してコードやコンポーネント( Animator など)の有効・無効化を行い、要素の表示中にだけ実行が必要な挙動を管理することができます。

メソッドコールのオーバーヘッドの削減

単純なライブラリ コードに追加的なメソッドコールを加える場合のコストをに関しては、非常に有益なケーススタディが C# の文字列ライブラリで提供されています。ビルトイン文字列 API の String.StartsWith および String.EndsWith に関するセクションで、不適切なロケール強制が抑制された場合においても、ビルトインのメソッドと比べて手動でのコードの置き換えのほうが 10~100 倍速いことが言及されています。

このようにパフォーマンスに差が出る主な理由は、単純に、タイトな内部ループに追加的なメソッドコールを加えるコストによるものです。呼び出されるメソッドのそれぞれが、それ自体のアドレスをメモリ内で特定し、別のフレームをスタックに押し上げなければなりません。この両方の処理にそれぞれコストが掛かりますが、これはほとんどのコードにおいては無視しても問題ない小さなコストです。

ただし、小さなメソッドをタイトなループ内で実行している場合は、メソッドコールの追加によるオーバーヘッドが大きくなることもあり、それが大部分を占めるまでになる可能性もあります。

以下の二つのメソッドをご覧ください。

例 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# generic List<int> の整数の合計値を算出します。1 つ目の例は、自動生成されたプロパティを使用してデータ値をホールドするという点で、より「新しい形の C#」であると言えます。

表面的にはこれら二つのコードは同等であるように見受けられますが、メソッドコールの観点からコードを分析すると違いが分かります。

例 1

int Accum { get; set; }
Accum = 0;

for(int i = 0;
       i < myList.Count;    //  List::getCount を呼び出す
       i++) {
    Accum       //  set_Accum を呼び出す
+=      // get_Accum を呼び出す
myList[i];  //  List::get_Value を呼び出す
}

毎回のループ実行時には 4 つのメソッドコールがあります。

  • myList.CountCount プロパティに get メソッドを呼び出します。
  • Accum プロパティの get および set メソッドが呼び出される必要があります。
  • getAccum の現在の値を検索し、追加操作に渡せるようにします。
  • set で 追加操作の結果を Accum に割り当てます。
  • 演算子 [] はリストの get_Value メソッドを呼び出し、リストの特定のインデックスにあるアイテムの値を検索します。

例 2

int accum = 0;
int len = myList.Count;

for(int i = 0;
    i < len; 
    i++) {
    accum += myList[i]; //  List::get_Value を呼び出す
}

例 2 では、 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 フレームに何千回(またはそれ以上)の頻度で実行される場合は若干の違いが出ます。

単純なプリミティブ型には、代わりに const 値を使用してください。 Const 値はコンパイル時間にインライン化されます。 const 変数への参照は、その値に置き換えられます。

(注) const 変数への参照は全てその値に置き換えられるので、長い文字列などの大きなデータタイプを const として宣言することは推奨されません。これを行うと、最終的な命令コード内に多数の重複データが発生し、最終的なバイナリーのサイズを必要以上に増大させます。

const が適さない場合には、代わりに static readonly 変数を作成してください。プロジェクトによっては、 Unity ビルトインの自明なプロパティさえも static readonly 変数に置き換えることで、パフォーマンスを微妙に向上させています。

自明なメソッド

自明なメソッドに関しては更に複雑です。機能の宣言を一回行い、それを他の場所で再使用できれば非常に便利です。しかし、タイトな内部ループ内では、本来の理想的なコーディング方法を逸脱し、一部のコードを「手動でインライン化」しなければならない場合もあります。

一部のメソッドは完全になくすことができます。 Quaternion.SetTransform.Translate、あるいは Vector3.Scale を考えてみましょう。これらは非常に自明な操作を実行するもので、単純な代入文と置き換えることができます。

より複雑なメソッドの場合は、手動によるインライン化のコストと、より効率的なコードの管理に掛かる長期的コストを、プロファイリング分析で比較してください。

一般的な最適化
アセットローディングメトリクス