【Unity】デリゲートを指定するときにGCAllocを減らす

あるメソッドの引数にデリゲートが必要な場合、GC Allocationを減らす方法の紹介です。

以下状況での GC Alloc が発生する話になります。

public static class SampleUtility
{
    // デリゲートを引数に取るメソッド
    public int Execute(Action<int> method) { ... }
}

public class MyObject : MonoBehaviour
{
    private void Update()
    {
        // ★GCAllocが発生しない(初回のみ発生)
        SampleUtility.Execute(StaticMethod);

        // ★GCAllocが発生!!!(毎回発生する)
        SampleUtility.Execute(InstanceMethod);
    }

    // 引数で渡すメソッド(1)
    private static void StaticMethod(int value) { ... }

    // 引数で渡すメソッド(2)
    private static void InstanceMethod(int value) { ... }
}

上記の例のコメントの通り

  • インスタンスメソッドを渡すと毎回GC Alloc発生する
  • static メソッドの時はGC Allocが発生しない

となります。

インスタンスメソッドの時だけ GCAlloc が常に発生します。

確認環境

  • Unity 2021.3.24f1

エディター上でプロファイラーを起動して確認しています。

本記事は、Unity を意識して書いていますが通常の .NET であれば共通の問題なのでサーバーサイドやデスクトップでも有効なアプローチです。

解決方法

先にGCAllocを発生させない方法です。

この問題は、スマートな方法が存在しないため回避するコードを自分で記述する必要があります。メソッドの引数にインスタンスメソッドを渡す場合以下のように記述します。

public class MyObject : MonoBehaviour
{
    // ★追加、インスタンスメソッドを保持するための変数
    Action<int> _method;

    private void Update()
    {
        // ★以下の通り書き換える
        SampleUtility.Execute(_method ??= InstanceMethod);
        //  → GCAllocは2回目以降発生しなくなる
    }

    // 引数で渡すメソッド
    private static void InstanceMethod(int value) { ... }
}

この修正で、初回の呼び出し時にデリゲートが生成され、2回目以降の呼び出しでは生成されたデリゲートが再利用されるため、GCAllocが発生しなくなります。

GCAllocが発生する理由

なぜインスタンスメソッドを渡したときだけ、GCAlloc が発生するかですが、最初の例題では概ね以下のようにコードが展開されます。

public class MyObject : MonoBehaviour
{
    // ★自動的にこのクラスが追加される
    private static class AutomaticGeneration
    {
        public static Action<int> __StaticMethod;
    }

    private void Update()
    {
        SampleUtility.Execute(StaticMethod);
        //
        // こんな感じに展開される↓↓↓↓
        //
        SampleUtility.Execute(AutomaticGeneration.__StaticMethod ??
            (AutomaticGeneration.__StaticMethod = new Action<int>(StaticMethod));

        SampleUtility.Execute(InstanceMethod);
        //
        // こんな感じに展開される↓↓↓↓
        //
        SampleUtility.Execute(new Action<int>(InstanceMethod)); // ★知らないうちにnewされてる
    }

    // 引数で渡すメソッド(1)
    private static void StaticMethod(int value) { ... }

    // 引数で渡すメソッド(2)
    private static void InstanceMethod(int value) { ... }
}

static メソッドのほうはコンパイラーが気を利かせてコードを追加して複数回の new を抑制するようになってますが、インスタンスメソッドのほうはそういった展開がされずに毎回デリゲートを new しています。

これが GCAlloc の正体です。なので、インスタンスメソッドの時はコンパイラーが追加しているコードを真似して実装を自分で追加します。

Action<int> _method;

private void Update()
{
    // こう書くのは面倒なので...
    SampleUtility.Execute(_method ?? (_method = new Action<int>(Foo)));

    // このように記述する(結局意味は同じです)
    SampleUtility.Execute(_method ??= new Action<int>(Foo));
}

そのうち気の利いたコードをコンパイラーが生成するようになればこの実装は不要ですが現状このように実装を状況に応じて追記が必要です。

ちなみにラムダ式で包むと効果があるとかありますが、以下のように展開されるので意味ありません(static メソッドは自動的にコード保管されてGCAlloc発生しない、インスタンスメソッドは意味ないとなります)

private void Update()
{
    SampleUtility.Execute(v => Foo(v));
    ↓
    // 結局こうやって展開されて毎回Actionがnewされる
    SampleUtility.Execute(new Action<int>(...));
}

まー、実際 Unity ではインクリメンタルGCを有効化していればこの例程度の実装が多少あって毎フレーム処理があってもほぼ問題起きないです。デスクトップ環境やサーバーサイドだとそもそも存在を認識するほど問題が表面化しません。気になる人は対応しておきましょう。