Built-in C# event と UnityEvent のパフォーマンス比較

処理速度とGC Allocの観点から,C#のeventとUnityEventを利用した時のパフォーマンス比較.

Jackson Dunstanの記事の翻訳です.

jacksondunstan.com

毎週月曜に記事が公開されている模様なので,翻訳しつつ追ってみます.

C# Event と UnityEvent の呼び出し方の例

C# Event

Action型を定義.
リスナーがいない場合も考慮して,?.演算子を付与している.
(個人的にはnullチェックの簡略化のために,よく?.演算子を利用している.)

class MyClass
{
    // Listener追加のためのevent宣言
    event Action<int,int> OnClick;
 
    void Foo()
    {
            // event発火.コールバック実行.
            OnClick?.Invoke(11, 22);
    }
}
 
// +=を利用して,リスナーを追加し,コールバック関数を定義
var myc = new MyClass();
myc.OnClick += (x, y) => Debug.LogFormat("clicked at {0}, {0}", x, y);

UnityEvent

引数を2~4まで定義したい場合は,継承クラスを作成し,Inspector表示のために[Serializable]の追加が必要.
引数が1つまでならば,上記のタスクは不要.

// UnityEventを利用する際の名前空間
using UnityEngine.Events;
 
// UnityEventを継承したクラスを作成し,[Serializable]をつける
[Serializable]
class Int2Event : UnityEvent<int, int>
{
}
 
class MyClass
{
    // UnityEvent宣言
    Int2Event OnClick = new Int2Event();
 
    void Foo()
    {
        // UnityEvent発火.コールバック実行.
        OnClick?.Invoke(11, 22);
    }
}
 
// AddListenerを利用して,リスナーを追加し,コールバック関数を定義
var myc = new MyClass();
myc.OnClick.AddListener((x, y) => Debug.LogFormat("clicked at {0}, {0}", x, y));

リスナー追加時の GC Alloc パフォーマンス比較

各種のイベントにリスナー追加を増やしていき,両者のGC Alloc を比較.

検証コード

using System;
 
using UnityEngine;
using UnityEngine.Events;
 
public class TestScript : MonoBehaviour
{
    event Action csharpEv0;
    UnityEvent unityEv0 = new UnityEvent();
 
    void Start()
    {
        AddCsharpListener();
        AddUnityListener();
        AddCsharpListener2();
        AddUnityListener2();
        AddCsharpListener3();
        AddUnityListener3();
        AddCsharpListener4();
        AddUnityListener4();
    }
 
    void AddCsharpListener()
    {
        csharpEv0 += NoOp;
    }
 
    void AddUnityListener()
    {
        unityEv0.AddListener(NoOp);
    }
 
    void AddCsharpListener2()
    {
        csharpEv0 += NoOp;
    }
 
    void AddUnityListener2()
    {
        unityEv0.AddListener(NoOp);
    }
 
    void AddCsharpListener3()
    {
        csharpEv0 += NoOp;
    }
 
    void AddUnityListener3()
    {
        unityEv0.AddListener(NoOp);
    }
 
    void AddCsharpListener4()
    {
        csharpEv0 += NoOp;
    }
 
    void AddUnityListener4()
    {
        unityEv0.AddListener(NoOp);
    }
 
    static void NoOp(){}
}

結果

f:id:xrdnk:20200504235035p:plain

リスナー数が0~1個の場合は,C# event の方がゴミは少ない.
リスナー数が2個以上になった場合,UnityEvent の方がゴミが少なくなる.

イベント発火時の GC Alloc パフォーマンス比較

リスナー数を2個とした場合のイベント発火時のパフォーマンス比較.

検証コード

using System;
 
using UnityEngine;
using UnityEngine.Events;
 
public class TestScript : MonoBehaviour
{
    event Action csharpEv0;
    UnityEvent unityEv0 = new UnityEvent();
 
    string report;
 
    void Start()
    {
        csharpEv0 += NoOp0;
        csharpEv0 += NoOp0;
        unityEv0.AddListener(NoOp0);
        unityEv0.AddListener(NoOp0);
        DispatchCsharpEvent();
        DispatchUnityEvent();
    }
 
    void DispatchCsharpEvent()
    {
        csharpEv0.Invoke();
    }
 
    void DispatchUnityEvent()
    {
        unityEv0.Invoke();
    }
 
    static void NoOp0(){}
}

結果

f:id:xrdnk:20200504235837p:plain

イベント発火時はC# event はゴミを生成しないが,UnityEventは生成している.
つまり,イベント発火時の場合は,C# event の方がパフォーマンスはよい

一応,UnityEventがゴミを生成するのは最初の発火の際であり,それ以降の発火時は生成しない.

処理速度のパフォーマンス比較

イベントの束をたくさん発火させて,比較.
発火回数は1000万回,引数が0~2個のイベントを1~5個のリスナーに送る.

検証時の環境

2.3 Ghz Intel Core i7-3615QM
Mac OS X 10.11.2
Apple SSD SM256E, HFS+ format
Unity 5.3.1f1, Mac OS X Standalone, x86_64, non-development
640×480, Fastest, Windowed

検証コード

using System;
 
using UnityEngine;
using UnityEngine.Events;
 
public class TestScript : MonoBehaviour
{
    const int NumReps = 10000000;
    const int MaxListeners = 5;
    const int MaxArgs = 3;
 
    [Serializable]
    class IntEvent1 : UnityEvent<int>
    {
    }
 
    [Serializable]
    class IntEvent2 : UnityEvent<int,int>
    {
    }
 
    event Action csharpEv0;
    event Action<int> csharpEv1;
    event Action<int,int> csharpEv2;
    UnityEvent unityEv0 = new UnityEvent();
    IntEvent1 unityEv1 = new IntEvent1();
    IntEvent2 unityEv2 = new IntEvent2();
 
    string report;
 
    void Start()
    {
        var stopwatch = new System.Diagnostics.Stopwatch();
        var csharpEvTimes = new long[MaxArgs,MaxListeners];
        var unityEvTimes = new long[MaxArgs,MaxListeners];
        for (var numListeners = 0; numListeners < MaxListeners; ++numListeners)
        {
            csharpEv0 += NoOp0;
            stopwatch.Reset();
            stopwatch.Start();
            for (var j = 0; j < NumReps; ++j)
            {
                csharpEv0.Invoke();
            }
            csharpEvTimes[0,numListeners] = stopwatch.ElapsedMilliseconds;
 
            unityEv0.AddListener(NoOp0);
            stopwatch.Reset();
            stopwatch.Start();
            for (var j = 0; j < NumReps; ++j)
            {
                unityEv0.Invoke();
            }
            unityEvTimes[0,numListeners] = stopwatch.ElapsedMilliseconds;
        }
        for (var numListeners = 0; numListeners < MaxListeners; ++numListeners)
        {
            csharpEv1 += NoOp1;
            stopwatch.Reset();
            stopwatch.Start();
            for (var j = 0; j < NumReps; ++j)
            {
                csharpEv1.Invoke(11);
            }
            csharpEvTimes[1,numListeners] = stopwatch.ElapsedMilliseconds;
 
            unityEv1.AddListener(NoOp1);
            stopwatch.Reset();
            stopwatch.Start();
            for (var j = 0; j < NumReps; ++j)
            {
                unityEv1.Invoke(11);
            }
            unityEvTimes[1,numListeners] = stopwatch.ElapsedMilliseconds;
        }
        for (var numListeners = 0; numListeners < MaxListeners; ++numListeners)
        {
            csharpEv2 += NoOp2;
            stopwatch.Reset();
            stopwatch.Start();
            for (var j = 0; j < NumReps; ++j)
            {
                csharpEv2.Invoke(11, 22);
            }
            csharpEvTimes[2,numListeners] = stopwatch.ElapsedMilliseconds;
 
            unityEv2.AddListener(NoOp2);
            stopwatch.Reset();
            stopwatch.Start();
            for (var j = 0; j < NumReps; ++j)
            {
                unityEv2.Invoke(11, 22);
            }
            unityEvTimes[2,numListeners] = stopwatch.ElapsedMilliseconds;
        }
 
        report = "Num Args,Num Listeners,C# Event Time,UnityEvent Time\n";
        for (var i = 0; i < MaxArgs; ++i)
        {
            for (var j = 0; j < MaxListeners; ++j)
            {
                report += i + "," + (j+1) + "," + csharpEvTimes[i,j] + "," + unityEvTimes[i,j] + "\n";
            }
        }
    }
 
    void OnGUI()
    {
        GUI.TextArea(new Rect(0, 0, Screen.width, Screen.height), report);
    }
 
    static void NoOp0(){}
    static void NoOp1(int a){}
    static void NoOp2(int a, int b){}
}

結果

引数が0のとき

f:id:xrdnk:20200505001134p:plain

引数が1のとき

f:id:xrdnk:20200505001200p:plain

引数が2のとき

f:id:xrdnk:20200505001210p:plain

考察

UnityEvent の処理速度は少なくとも C# event の2倍はかかる.
最悪のケースでは40倍くらいの時間がかかる.

結論

メモリの観点

リスナー数が1個までの場合は,C# eventの方がメモリを食わない.
リスナー数が2個以上の場合は,UnityEventの方がメモリを食わない.

最初のイベント発火時では,UnityEventはメモリを食うが,C#eventは食わない.

処理速度

C# event の方が UnityEvent より処理速度が最低2倍程度早い.

終わりに

これから毎週月曜日にJackson Dunstan氏の記事を翻訳して紹介しようと思います.
結構C#とUnityに関するノウハウがたまってる….

C# event の方が UnityEvent より速いのですが,UnityEventはInspectorで簡単にリンクできるのが強みで,
デザイナーや初心者がプロジェクトにいる場合はUnityEventを利用した方が学習コストを考慮すると良い場合がある.