【Unity】ガベージコレクション(GC)による負荷を減らすための最適化方法まとめ

Unity GC.Allocって何? ゲーム開発

今回はUnityのC#および最適化に関する中級者向けの話題で

  • GC.Allocとは何か
  • ガベージコレクション(GC)による負荷を減らすための最適化方法

といった点について解説するという内容となっております。

Unityでプロファイラーをチェックしていると「GC.Alloc」という項目がやたら大きな負荷になっており、グラフがトゲ状(=スパイク)になっていることがよくあります。このときプロファイラーを使った分析にあまり慣れていない方だったら

  • これは一体何だろう?
  • どうやって対処すればいいんだ?

と思うのではないでしょうか。

そこでここではこのGC.Allocという謎の項目についてと、ガベージコレクションによる負荷をなるべく発生させないための最適化のコツをご紹介していきますね。

GC.Allocとガベージコレクション

GC.Allocとは?

まずはじめに、GC.Allocとはメモリの動的割り当てのことです。GC.AllocはC# スクリプトが新しいオブジェクトをメモリ上に動的に作成(割り当て)する際に発生します。

この動的な割り当て自体は別に悪者ではないのですが、問題はメモリ上に新しいオブジェクトの割り当てが頻繁に行われたときに、使われなくなったオブジェクト(=ゴミ)がどんどん溜まってメモリを圧迫してしまう点にあります。

ガベージコレクション(=GC)について

ただC#においてはメモリにゴミが溜まりっぱなしということにはなりません。なぜならガベージコレクション(GC)という処理が実行されるからです。

ガベージコレクションとはメモリ上の未使用の領域を自動的に解放することで、C#には標準機能として搭載されています。この処理のおかげでプログラマはメモリ管理にあまり神経を使うことなくC#スクリプトを書くことができます。

…しかし残念なことに「じゃあガベージコレクションがあれば万事解決だね!」とはなりません。確かにガベージコレクションのおかげでメモリは自動的にきれいになりますが、実はガベージコレクションの処理は重いので例えばゲーム実行中に大量のゴミを片付けることになると処理落ちが発生してしまうのです。

ゴミを出さないC#スクリプトを書こう

それならどうすればいいのかというと重要なのはそもそもゴミを出さないようにすることです。ゲームが重くなる理屈としては

  1. 動的割り当てが発生しゴミが出る
  2. そのゴミが溜まる
  3. 溜まったゴミを掃除する処理が走る
  4. その結果ゲームが重くなる

というわけですから、大元のゴミを出さなければ動的割り当ては発生せず、ガベージコレクションの処理も走りません。つまりゴミを出さないようにすればゲームがサクサク動くってわけです(※まあ他に重い処理をガンガンやってたらダメだけどね…)。

もちろんゴミを全く出さないようにするのは難しいのですが、工夫次第でゴミを減らすことはできるのでその辺の方法はちゃんと知っておくとよいでしょう。

メモリにゴミが発生する主な原因まとめ

では一体どのような場合にメモリにゴミが発生するのでしょうか?原因は多岐にわたるのですがよくあるものをまとめると下記が挙げられます。

  • 参照型のインスタンスを作成する
  • 値型のボックス化を行う
  • 文字列の操作を行う
  • ラムダ式でキャプチャを発生させる
  • Unity特有のメモリ確保の問題

それぞれザックリと見ていきましょう。

原因1:クラスなど参照型のインスタンスを作成する

まず一つ目の原因はクラスなど参照型のインスタンスを作成することです。「参照型をnewする」(=参照型のインスタンスを作成する)と動的割り当てが発生するのでゴミが溜まる原因になります。

class MyClass {
    public int Value;
}

MyClass obj = new MyClass();

…まあこれをやらないといけない場面はかなり多くて全く使わないわけにもいかないのですが、せめて頻繁にインスタンスを作ってしまわないように気をつけるようにするといいと思います。

ちなみに、後述しますが構造体など値型の場合はnewしても動的割り当ては発生しません。Unityでいうと例えばVector3は構造体なのでnewしても大丈夫です。

原因2:値型のボックス化を行う

次に二つ目の原因は値型のボックス化を行うことです。ボックス化とは下記のように値型(intやfloatなど)の値を参照型(object型)に変換することをいいます。

object boxed = 42;

原因3:文字列の操作を行う

三つ目の原因は文字列の操作を行うことです。例えば下記のように文字列を連結する操作を行うと動的割り当てが発生します。

string str = "Hello" + "World";

原因4:ラムダ式でキャプチャを発生させる

四つ目の原因はラムダ式でキャプチャを発生させることです。ラムダ式のキャプチャとは、下記のようにラムダ式内で外部の変数を使用することです。

int counter = 0; // 外部変数

Action action = () => {
    counter++; // 外部変数をキャプチャ
};

キャプチャが発生すると、キャプチャされた変数を保持するためのクラスが内部的に作成されるので動的割り当てが発生します。

原因5:Unity特有のメモリ確保の問題

五つ目の原因はUnity特有の問題です。例えばゲームオブジェクト名(UnityEngine.Object.name)にアクセスすると動的割り当てが発生します。

これを知らずに毎フレームアクセスしたりするとメモリにどんどんゴミが溜まる→GCが走ってゲームがカクつく…なんてことになりかねないので注意しましょう。一見無害そうでいて地味に罠なのが怖いところですね。

おまけ:なぜ参照型は動的割り当てが発生するのか?

さて主な原因はこんなところですが、↑の原因1や原因2を見て鋭い方は

  • なんで参照型のインスタンスを作ると動的割り当てが発生するんだろう?
  • どうして値型はnewしてもゴミが溜まらないんだ??

と思うかもしれません。そこでこの辺についてごく簡単にご説明しておきます(※ただし私は専門家ではないのでフワッとした書き方になってしまうかもしれない点は予めご了承ください)。

メモリの「スタック領域」と「ヒープ領域」

まず、動的割り当ての仕組みを知るにはメモリの構造を知る必要があります。メモリには全部で4つの領域があり、そのうちの2つがスタック領域ヒープ領域です。

スタック領域

スタック領域とは後入れ先出し方式(=LIFO)で管理される、サイズ制限のある領域です。一時的なデータや関数のローカル変数の管理に適しておりきわめて効率よく管理されます。

値型は基本的にスタック領域に格納されます。

ヒープ領域

ヒープ領域とは動的に割り当てられるサイズ制限のない領域です。長期間使用するデータや実行時にサイズが決まるオブジェクトの管理に適しており、柔軟性は高いですが複雑で速度が遅いという欠点があります。

参照型はヒープ領域に格納されます。

参照型はヒープ領域に格納されるので動的に割り当てられる

そんなわけで、メモリの仕組みを踏まえると

  • 参照型はヒープに格納されるので動的割り当てが発生する
  • 値型は原則スタックに格納されるので、基本的に動的割り当ては発生しない

ということになります。

ただし例外的に値型であってもボックス化を行うと動的割り当てが発生します。これは↑のほうでご紹介した通りボックス化では値型を参照型として扱うからです。

ガベージコレクションによる負荷を減らすための最適化方法まとめ

ではここまでの内容を踏まえて、ガベージコレクションによる負荷を減らすための最適化方法をいくつかご紹介していきます。

  • 「インクリメンタルGC」機能を有効化する
  • 頻繁に使うクラス等はキャッシュして使い回す(頻繁にnewしない)
  • ボックス化を避ける
  • 文字列の操作を頻繁に行わない
  • ラムダ式のキャプチャを避ける
  • コレクションに予めキャパシティを設定しておく

それぞれ詳しく見ていきましょう。

「インクリメンタルGC」機能を有効化する

まず一つ目にしてなかなか強力な方法がUnityの「インクリメンタルGC」という機能を有効化することです。この機能を有効化すると、ガベージコレクションの処理を分散するようになり処理落ちが発生しにくくなります。

有効化方法はUnityエディタのプロジェクト設定ウィンドウ→「プレイヤー」→「差分GCを使用」にチェックを入れるだけです(※この設定を変更するとUnityが再起動します)。

インクリメンタルGCの有効化方法

ただし、インクリメンタルGCはゴミの発生を抑える方法ではないためその点には注意してください。

頻繁に使うクラス等はキャッシュして使い回す(頻繁にnewしない)

次に二つ目は頻繁に使うクラス等はキャッシュして使い回すことです。キャッシュすることでいちいちインスタンスを作成する必要がなくなるためゴミの発生を抑えることができます。

例えば、案外知られていないことですがコルーチン内でよく使うWaitForSecondsも頻繁にnewするとゴミになります。そこで下記のサンプルスクリプトのようにキャッシュを生成してそれを使い回すことでゴミの発生を防ぐようにしましょう。

public class CoroutineExample : MonoBehaviour
{
    private WaitForSeconds waitForSeconds; // 再利用するインスタンス

    private void Start()
    {
        waitForSeconds = new WaitForSeconds(2f); // 事前に生成
        StartCoroutine(MyCoroutine());
    }

    private IEnumerator MyCoroutine()
    {
        while (true)
        {
            Debug.Log("Waiting...");
            yield return waitForSeconds; // 再利用
        }
    }
}

ボックス化を避ける

三つ目はボックス化を避けることです。例えばint型やfloat型などをObject型に変換するのはなるべくやめるようにしてください。

文字列の操作を頻繁に行わない

四つ目は文字列の操作を頻繁に行わないことです。もしどうしても文字列の操作を頻繁に行わなければいけない場合はStringBuilderを使うようにしましょう。

ラムダ式のキャプチャを避ける

五つ目はラムダ式のキャプチャを避けることです。ラムダ式内ではなるべく外部変数を参照しないようにしましょう。

コレクションに予め要素数を設定しておく

最後に六つ目はコレクション(ListやDictionary等)をnewするときは要素数を設定しておくことです。要素数を指定しないと無駄にメモリが確保されることがあり非効率的なので、使用する大体の要素数が事前にわかっている場合は最初に指定しておくと無駄がなくなり動的割り当てが発生しにくくなります。

例えば、要素を10個くらいしか使わないことが予め分かっているListなら次のようにします。

var list = new List<int>(10);

こうすることでメモリの無駄な領域が確保されるのを防ぐことができます。

ただし要素数を指定しても最初からその分の要素がList内に用意されるわけではないので注意してください。あと、指定した要素数はあくまでも目安なので要素数がそれを超えた場合でも普通に使えます。

おわりに

以上、GC.Allocについての話やガベージコレクションの負荷を減らすための最適化手法についての話をしました。

ガベージコレクションに関する話はメモリの仕組みとかが関わってきて結構難しいと感じる方も多いかと思いますが、理屈が分かれば対策は簡単にできることばかりですのでご紹介したことをぜひお試しいただければと思います。

この記事がUnityでのゲーム開発のお役に立てば幸いです。