Version: 2023.2
言語: 日本語
ガベージコレクションを無効にする
Native memory

ガベージコレクションのベストプラクティス

ガベージコレクション は自動で行われますが、その処理にはかなりの CPU 時間が必要です。

C# の自動メモリ管理は、割り当てたメモリを全て手動で追跡して解放しなければならない C++ などの他のプログラミング言語と比較して、メモリリークなどのプログラミングエラーのリスクを低減します。

自動メモリ管理は、エラーの少ないコードを素早く簡単に記述することを可能にします。しかし、この利便性は、パフォーマンスに影響を与える可能性も内包しています。パフォーマンスのためにコードを最適化するには、アプリケーションが ガベージコレクター を大量にトリガーするような状況を回避する必要があります。このセクションでは、アプリケーションによるガベージコレクターのトリガーに影響する、一般的な問題とワークフローについて概説します。

一時割り当て

アプリケーションがフレームごとに マネージヒープ に一時的なデータを割り当てるのは、よくあることです。しかし、これはアプリケーションのパフォーマンスに影響を及ぼす可能性があります。例えば、以下のような例が考えられます。

  • フレームごとに 1 キロバイト (1KB) の一時メモリを割り当て、毎秒 60 フレームで実行されるプログラムは、毎秒 60 キロバイトの一時メモリを割り当てなければなりません。1 分間で、積み重なったガベージコレクターの使用可能メモリは 3.6 メガバイトになります。
  • ガベージコレクターを 1 秒に 1 回起動すると、パフォーマンスに悪影響が及ぼされます。ガベージコレクターが 1 分間に 1 回しか実行されない場合は、何千もある個々の割り当てに散らばった 3.6 メガバイトをクリーンアップしなければならず、結果的にガベージコレクション時間が非常に長くなる可能性があります。
  • ロード操作はパフォーマンスに影響を及ぼします。アプリケーションが負荷の高いアセット読み込み操作中に大量の一時オブジェクトを生成し、その操作が完了するまで Unity がそれらのオブジェクトを参照する場合、ガベージコレクターはそれらの一時オブジェクトを解放することができません。これはつまり、マネージヒープが (そこに含まれるオブジェクトの多くは Unity によってその後すぐに解放されるにも関わらず) 拡張されなければならないことを意味します。

これを回避するには、頻繁に管理されるヒープ割り当ての量をできるだけ減らす必要があります。できれば 1 フレームにつき 0 バイト、または限りなく 0 に近い状態にするのが理想的です。

再利用可能なオブジェクトプール

ガベージの生成を回避するために、オブジェクトの作成と破棄の回数を削減できるケースは数多くあります。ゲーム中のオブジェクトの中には、例えば弾丸などの、少数しか同時に使用されないにもかかわらず繰り返し登場するタイプのものがあります。こういったケースでは、古いオブジェクトを破棄して新しいものに置き換えるのではなく、オブジェクトを再利用することができます。

例えば、弾丸が発射されるたびにプレハブから新しい弾丸オブジェクトをインスタンス化するのは理想的ではありません。代わりに、ゲームプレイ中に同時に存在しうる弾丸の最大数を計算し、ゲームが最初にゲームプレイシーンに入ったときに、適切なサイズのオブジェクトの配列をインスタンス化することができます。これは、以下の方法で行えます。

  • 全ての弾丸ゲームオブジェクトが非アクティブに設定された状態で開始します。
  • 弾丸が発射されたら、配列内をサーチして、配列内の最初の非アクティブな弾丸を見つけます。これを適切な位置に移動して、ゲームオブジェクトをアクティブに設定します。
  • 弾丸が破壊されたら、再びゲームオブジェクトを非アクティブに設定します。

この再利用可能なオブジェクトプールを用いた方法の実装を提供する、ObjectPool クラスを使用することが可能です。

以下のコードは、スタックベースのオブジェクトプールの簡単な実装です。ObjectPool API が含まれていない古いバージョンの Unity を使用している場合や、カスタムオブジェクトプールの実装例を見たい場合に参照してください。

using System.Collections.Generic;
using UnityEngine;

public class ExampleObjectPool : MonoBehaviour {

   public GameObject PrefabToPool;
   public int MaxPoolSize = 10;
  
   private Stack<GameObject> inactiveObjects = new Stack<GameObject>();
  
   void Start() {
       if (PrefabToPool != null) {
           for (int i = 0; i < MaxPoolSize; ++i) {
               var newObj = Instantiate(PrefabToPool);
               newObj.SetActive(false);
               inactiveObjects.Push(newObj);
           }
       }
   }

   public GameObject GetObjectFromPool() {
       while (inactiveObjects.Count > 0) {
           var obj = inactiveObjects.Pop();
          
           if (obj != null) {
               obj.SetActive(true);
               return obj;
           }
           else {
               Debug.LogWarning("Found a null object in the pool. Has some code outside the pool destroyed it?");
           }
       }
      
       Debug.LogError("All pooled objects are already in use or have been destroyed");
       return null;
   }
  
   public void ReturnObjectToPool(GameObject objectToDeactivate) {
       if (objectToDeactivate != null) {
           objectToDeactivate.SetActive(false);
           inactiveObjects.Push(objectToDeactivate);
       }
   }
}

文字列連結の繰り返し

C# の文字列は不変な参照型です。参照型ということは、Unity によってマネージヒープ上に割り当てられ、ガベージコレクションの対象となることを意味します。不変とは、一度文字列が作成されると変更を加えられないことを意味します。文字列に変更を加えようとすると、全く新しい文字列が生成されます。このため、可能な限り、一時的な文字列の作成は避ける必要があります。

以下のサンプルコードでは、多数の文字列の組み合わせて 1 つの文字列にしています。ループ中に新しい文字列が追加されるたびに、結果の変数の以前の内容が冗長になり、コードは全く新しい文字列を割り当てます。

// Bad C# script example: repeated string concatenations create lots of
// temporary strings.
using UnityEngine;

public class ExampleScript : MonoBehaviour {
    string ConcatExample(string[] stringArray) {
        string result = "";

        for (int i = 0; i < stringArray.Length; i++) {
            result += stringArray[i];
        }

        return result;
    }

}

入力 stringArray に { "A", "B", "C", "D", "E" } が含まれる場合 、このメソッドは、以下の文字列用にヒープ上にストレージを生成します。

  • "A"
  • "AB"
  • "ABC"
  • "ABCD"
  • "ABCDE"

この例では、最後の文字列だけが必要で、他の文字列は冗長な割り当てです。入力配列内の項目が多ければ多いほど、このメソッドが生成する文字列が多くなり、それぞれが直前のものよりも長くなります。

多くの文字列を連結する必要がある場合は、Mono ライブラリの System.Text.StringBuilder クラスを使用してください。上記のスクリプトの改良版は以下のようになります。

// Good C# script example: StringBuilder avoids creating temporary strings,
// and only allocates heap memory for the final result string.
using UnityEngine;
using System.Text;

public class ExampleScript : MonoBehaviour {
    private StringBuilder _sb = new StringBuilder(16);

    string ConcatExample(string[] stringArray) {
        _sb.Clear();

        for (int i = 0; i < stringArray.Length; i++) {
            _sb.Append(stringArray[i]);
        }

        return _sb.ToString();
    }
}

連結の繰り返しは、頻繁に呼び出される (例: フレーム更新ごとの呼び出しなど) ことがない限り、あまりパフォーマンスを低下させません。以下の例は、Update が呼び出されるたびに新しい文字列を割り当て、ガベージコレクターが処理しなければならない連続的なオブジェクトのストリームを生成します。

// Bad C# script example: Converting the score value to a string every frame
// and concatenating it with "Score: " generates strings every frame.
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

このような、ガベージコレクションの連続的な必要性を回避するために、スコアが変更された時にのみテキストが更新されるようにコードを構成することができます。

// Better C# script example: the score conversion is only performed when the
// score has changed
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;
    
    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}

これをさらに改善する方法として、スコアタイトル ("Score: " という部分) とスコア表示を 2 つの異なる UI.Text オブジェクトに格納すれば、文字列連結が不要になります。以下のコードはまだスコア値を文字列に変換する必要がありますが、上記のバージョンからは改善されています。

// Best C# script example: the score conversion is only performed when the
// score has changed, and the string concatenation has been removed
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
   public Text scoreBoardTitle;
   public Text scoreBoardDisplay;
   public string scoreText;
   public int score;
   public int oldScore;

   void Start() {
       scoreBoardTitle.text = "Score: ";
   }

   void Update() {
       if (score != oldScore) {
           scoreText = score.ToString();
           scoreBoardDisplay.text = scoreText;
           oldScore = score;
       }
   }
}

配列値を返すメソッド

場合によっては、新しい配列を作成し、その配列を値で埋めて返すメソッドを記述すると便利なことがあります。しかし、このメソッドが繰り返し呼び出されると、そのたびに新しいメモリが割り当てられることになります。

以下のサンプルコードは、呼び出されるたびに配列を作成するメソッドの例です。

// Bad C# script example: Every time the RandomList method is called it
// allocates a new array
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];
        
        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }
        
        return result;
    }
}

メモリを毎回割り当てるのを回避するひとつの方法として、配列が参照型であることを利用できます。パラメーターとしてメソッド内に渡された配列を変更しても、メソッドが返された後に結果を残すことができます。これを行うには、サンプルコードを以下のように調整します。

// Good C# script example: This version of method is passed an array to fill
// with random values. The array can be cached and re-used to avoid repeated
// temporary allocations
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}

このコードは、配列の既存の内容を新しい値に置き換えます。このワークフローでは、呼び出す側のコードで配列の初期割り当てを行う必要がありますが、この関数は、呼び出された時に新しいガベージを一切生成しません。その後、次にこのメソッドが呼び出された時に、マネージヒープ上に新たな割り当てを発生させることなくこの配列を再使用して乱数で埋め直すことができます。

Collection と配列の再利用

System.Collection 名前空間から配列やクラス (例えば List や Dictionary) を使用する場合、割り当てられたコレクションや配列を再利用するかプールするのが効率的です。コレクションクラスは Clear メソッドを公開し、これはコレクションの値を消去しますが、コレクションに割り当てられたメモリは解放しません。

これは、複雑な計算用に一時的な “ヘルパー” コレクションを割り当てたい場合に役立ちます。以下のサンプルコードではこれを行っています。

// Bad C# script example. This Update method allocates a new List every frame.
void Update() {

    List<float> nearestNeighbors = new List<float>();

    findDistancesToNearestNeighbors(nearestNeighbors);

    nearestNeighbors.Sort();

    // … use the sorted list somehow …
}

このサンプルコードは、データポイントのセットを収集するために nearestNeighbors List を 1 フレームにつき 1 回割り当てます。

この List をメソッドの外に出し、クラスに割り当てることで、フレームごとに新しい List を割り当てなくて済むようにできます。

// Good C# script example. This method re-uses the same List every frame.
List<float> m_NearestNeighbors = new List<float>();

void Update() {

    m_NearestNeighbors.Clear();

    findDistancesToNearestNeighbors(NearestNeighbors);

    m_NearestNeighbors.Sort();

    // … use the sorted list somehow …
}

このサンプルコードは、複数のフレームにわたって List のメモリを保持し再利用します。このコードは、List の拡張が必要な時にだけ新しいメモリを割り当てます。

クロージャと匿名メソッド

基本的には、C# 内では可能な限りクロージャを避ける必要があります。匿名メソッドとメソッド参照の使用は、パフォーマンスに影響を与えやすいコード 、特にフレームごとに実行されるコードでは、最小限に抑えてください。

C# のメソッド参照は参照型なので、ヒープ上に割り当てられます。つまり、メソッド参照を引数として渡せば、一時的な割り当ての作成が簡単に行えます。この割り当ては、渡すメソッドが匿名メソッドであるか定義済みメソッドであるかに関わらず発生します。

また、匿名メソッドをクロージャに変換すると、クロージャをメソッドに渡すために必要なメモリ量が大幅に増加します。

以下のサンプルコードでは、ランダムな数のリストが特定の順序でソートされる必要があります。これは匿名メソッドを使用してリストのソート順を制御するもので、ソートによって割り当てが生成されることがありません。

// Good C# script example: using an anonymous method to sort a list. 
// This sorting method doesn’t create garbage
List<float> listOfNumbers = getListOfRandomNumbers();


listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/2)) 

);

このスニペットを再利用可能にするために、定数 2 をローカルスコープの変数に置き換えることもできます。

// Bad C# script example: the anonymous method has become a closure,
// and now allocates memory to store the value of desiredDivisor
// every time it is called.
List<float> listOfNumbers = getListOfRandomNumbers();


int desiredDivisor = getDesiredDivisor();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/desiredDivisor))

);

この匿名メソッドは、スコープの外にある変数の状態にアクセスしなければならなくなりました。したがってメソッドはクロージャになりました。desiredDivisor 変数をクロージャ内に渡して、クロージャのコードがそれを使えるようにする必要があります。

クロージャに正しい値が渡されるように、C# は、クロージャが必要とする外部スコープ変数を保持できる、匿名クラスを生成します。クロージャが Sort メソッドに渡された時に、このクラスのコピーがインスタンス化され、そのコピーが desiredDivisor 整数の値で初期化されます。

このクロージャの実行には、その生成されたクラスのインスタンス化が必要であり、全てのクラスは C# では参照型であるため、このクロージャの実行には、マネージヒープ上のオブジェクトの割り当てが必要になります。

ボックス化

ボックス化は、Unity プロジェクトにおける、一時メモリの意図せぬ割り当ての原因として、最もよくあるもののひとつです。これは、値型の変数が自動的に参照型に変換される場合に発生します。これが最も頻繁に起こるのは、プリミティブな値型変数(int や float など)をオブジェクト型メソッドに渡す場合です。

この例では、x 内の整数を、object.Equals メソッドに渡せるようにボックス化しています。なぜなら、オブジェクトの Equals メソッドが、オブジェクトを 1 つ渡されることを必要とするからです。

int x = 1;

object y = new object();

y.Equals(x);

ボックス化は意図せぬメモリ割り当てを発生させる原因になりますが、C# IDE とコンパイラーは、ボックス化に関する警告は出しません。これは、C# が、“小さな一時割り当ては、世代別ガベージコレクターや、割り当てサイズに応じて調整されるメモリプールによって効率的に処理される” と想定しているためです。

Unity のアロケーターは小さな割り当てと大きな割り当てで異なるメモリプールを使用しますが、Unity の ガベージコレクター は世代別ではないため、ボックス化によって生成される頻繁で小さな一時割り当てを効率的に一掃することはできません。

ボックス化の特定

ボックス化は、使用されているスクリプティングバックエンドによって、くつかあるメソッドのうちいずれか 1 つに対する呼び出しとして、CPU トレースに表示されます。これは以下のいずれかの形式を取ります。(<example class> がクラスまたは構造体の名前、 が引数の数です。)

<example class>::Box(…)
Box(…)
<example class>_Box(…)

ボックス化を見つける方法として、デコンパイラーや IL (中間言語) ビューアー (ReSharper に組み込まれた IL ビューアーツールdotPeek デコンパイラ など) の出力を検索することも可能です。IL の命令 は box です。

配列型 Unity API

意図せぬ割り当て配列の原因として分かりにくいもののひとつは、配列を返す Unity API への繰り返しのアクセスです。配列を返す Unity API は全て、アクセスされるたびに配列の新しいコピーを作成します。コードが必要以上の頻度で配列型 Unity API へのアクセスを行う場合、パフォーマンスに悪影響が及ぶ可能性が高くなります。

例えば、以下のコードでは、ループの 1 回の反復ごとに頂点配列の複製が 4 つ、不必要に作成されます。.vertices プロパティへのアクセスが行われるたびに割り当てが発生します。

// Bad C# script example: this loop create 4 copies of the vertices array per iteration
void Update() {
    for(int i = 0; i < mesh.vertices.Length; i++) {
        float x, y, z;

        x = mesh.vertices[i].x;
        y = mesh.vertices[i].y;
        z = mesh.vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

このコードをリファクタリングして、ループの反復回数に関係なく 1 つの配列割り当てにすることができます。これを行うには、ループの前に頂点配列がキャプチャされるようにコードを構成します。

// Better C# script example: create one copy of the vertices array
// and work with that
void Update() {
    var vertices = mesh.vertices;

    for(int i = 0; i < vertices.Length; i++) {

        float x, y, z;

        x = vertices[i].x;
        y = vertices[i].y;
        z = vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

これを行うさらに良い方法は、キャッシュされてフレーム間で再利用される頂点の List を維持し、Mesh.GetVertices を使用して、必要に応じてそれを追加することです。

// Best C# script example: create one copy of the vertices array
// and work with that.
List<Vector3> m_vertices = new List<Vector3>();

void Update() {
    mesh.GetVertices(m_vertices);

    for(int i = 0; i < m_vertices.Length; i++) {

        float x, y, z;

        x = m_vertices[i].x;
        y = m_vertices[i].y;
        z = m_vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

プロパティへの一度のアクセスによる CPU パフォーマンスへの影響は大きくありませんが、タイトなループ内で繰り返しアクセスすると、CPU パフォーマンスのホットスポットが発生します。繰り返しのアクセスは マネージヒープ を拡張させます。

この問題はモバイルデバイスで頻繁に見られます。理由は、Input.touches API の動作が上記と類似しているからです。また、以下のような、.touches プロパティへのアクセスが行われるたびに割り当てが発生するコードがプロジェクトに含まれることもよくあります。

// Bad C# script example: Input.touches returns an array every time it’s accessed
for ( int i = 0; i < Input.touches.Length; i++ ) {
   Touch touch = Input.touches[i];

    // …
}

これを改善するために、配列の割り当てをループ条件から出してその前に置いてコードを構成することができます。

// Better C# script example: Input.touches is only accessed once here
Touch[] touches = Input.touches;

for ( int i = 0; i < touches.Length; i++ ) {

   Touch touch = touches[i];

   // …
}

次のコード例では、上記の例を、割り当てを発生させない Touch API に変換しています。

// BEST C# script example: Input.touchCount and Input.GetTouch don’t allocate at all.
int touchCount = Input.touchCount;

for ( int i = 0; i < touchCount; i++ ) {
   Touch touch = Input.GetTouch(i);

   // …
}

ノート: プロパティアクセス (Input.touchCount) は、このプロパティの get メソッドの起動による CPU への影響を抑えるために、ループ条件の外に残ります。

割り当てを発生させない代替 API

Unity の API の一部には、メモリ割り当てを発生させない代替版があります。可能な限りこれを使用してください。以下の表には、割り当てを発生させる一般的な API と、その代替となる割り当てを発生させない API が、いくつか記載されています。これは包括的なリストではなく、どのようなタイプの API に注意すべきかを示すものです。

割り当てを発生させる API 割り当てを発生させない代替 API
Physics.RaycastAll Physics.RaycastNonAlloc
Animator.parameters Animator.parameterCount and Animator.GetParameter
Renderer.sharedMaterials Renderer.GetSharedMaterials

空配列の再利用

開発チームによっては、配列型メソッドが空のセットを返す必要がある場合に、null ではなく空配列を返す方法を選びたい場合もあります。この記述パターンは、多くのマネージ言語、特に C# と Java で一般的です。

基本的に、長さ 0 の配列をメソッドから返す場合は、空配列を繰り返し生成するよりも、事前に割り当てられた長さ 0 の静的インスタンスを返す方が効率的です。

その他の参考資料

ガベージコレクションを無効にする
Native memory