はじめに
Unityでは、イベント処理を便利に記述できるUniRxというライブラリを使うことができます。
UniRxは便利な一方、使用した際の負荷もまた気になるところではないでしょうか。
今回は、UniRxのObservable
に対するSubscribe
実行時にどの程度のアロケーション(メモリ確保)が発生しているのかをUnityのProfilerを使って計測してみたいと思います。
テストコード
アロケーション調査に用いるスクリプトはこんな感じです(テスト用にDisposeなどは行っていません)。
using UniRx;
using UnityEngine;
using UnityEngine.Profiling;
public class UniRxTest : MonoBehaviour
{
private readonly CustomSampler sampler1 = CustomSampler.Create($"{nameof(subject1)}.OnNext");
private readonly Subject<int> subject1 = new();
private readonly CustomSampler sampler2 = CustomSampler.Create($"{nameof(subject2)}.OnNext");
private readonly Subject<int> subject2 = new();
private readonly CustomSampler sampler3 = CustomSampler.Create($"{nameof(subject3)}.OnNext");
private readonly Subject<int> subject3 = new();
private readonly CustomSampler sampler4 = CustomSampler.Create($"{nameof(subject4)}.OnNext");
private readonly Subject<int> subject4 = new();
private readonly CustomSampler sampler5 = CustomSampler.Create($"{nameof(subject5)}.OnNext");
private readonly Subject<int> subject5 = new();
private int hogeCount = 0;
private static int piyoCount = 0;
private void Start()
{
// 1. ラムダ式内で完結する場合
var startSampler1 = CustomSampler.Create($"{nameof(subject1)}.Subscribe");
startSampler1.Begin();
subject1.Subscribe(_ => { });
startSampler1.End();
var localVar = 1;
// 2. ローカル変数を使う場合
var startSampler2 = CustomSampler.Create($"{nameof(subject2)}.Subscribe");
startSampler2.Begin();
subject2.Subscribe(_ => localVar++);
startSampler2.End();
// 3. インスタンスメソッドを直接呼び出す場合
var startSampler3 = CustomSampler.Create($"{nameof(subject3)}.Subscribe");
startSampler3.Begin();
subject3.Subscribe(HogeMethod);
startSampler3.End();
// 4. インスタンスメソッドをSubscribeWithStateを使って呼び出す場合
var startSampler4 = CustomSampler.Create($"{nameof(subject4)}.Subscribe");
startSampler4.Begin();
subject4.SubscribeWithState(this, (count, own) => own.HogeMethod(count));
startSampler4.End();
// 5. 静的メソッドを直接呼び出す場合
var startSampler5 = CustomSampler.Create($"{nameof(subject5)}.Subscribe");
startSampler5.Begin();
subject5.Subscribe(PiyoMethod);
startSampler5.End();
}
private void Update()
{
sampler1.Begin();
subject1.OnNext(1);
sampler1.End();
sampler2.Begin();
subject2.OnNext(2);
sampler2.End();
sampler3.Begin();
subject3.OnNext(3);
sampler3.End();
sampler4.Begin();
subject4.OnNext(4);
sampler4.End();
sampler5.Begin();
subject5.OnNext(5);
sampler5.End();
}
private void HogeMethod(int hoge)
{
hogeCount += hoge;
}
private static void PiyoMethod(int piyo)
{
piyoCount += piyo;
}
}
このテストコードでは5つのパターンにおけるSubscribe
時のアロケーションを確認する他、ついでにOnNext
実行時のアロケーションも見るようにしています。
パターンごとの意図を説明します。
1. 処理がラムダ式内で完結する場合
Subscribe
に渡すラムダ式が外部変数のキャプチャを行わない、一番シンプルな形です。
2. ラムダ式内でローカル変数を使用する場合
Subscribe
に渡すラムダ式がローカル変数をキャプチャしている場合です。
ローカル変数をキャプチャするラムダ式はクロージャと呼ばれ、C#コンパイラは値を包み込む形で新規にクラスを自動生成するという挙動を示すようです。
参考
クロージャの生成は実行時の性能を求める場合は可能な限り避けるべきだというのがなんとなくの知識としてありましたが、実際どの程度の負荷がかかっているのでしょうか。
3. インスタンスメソッドを直接呼び出す場合
Subscribe
に直接インスタンスメソッドHogeMethod
を渡しています。
この場合C#コンパイラはデリゲートを生成して包むという挙動を示すようです。
参考
4. SubscribeWithStateを使用してインスタンスメソッドを呼び出す場合
Subscribe
の代わりにSubscribeWithState
を使って、インスタンスメソッドを呼び出す場合です。
外部変数を囲い込むことができるのでアロケーションは抑制できるはずですが、どうなるでしょうか。
SubscribeWithState
については、以下の記事が詳しいです(上でも紹介しましたが、UniRxの開発・保守をされている方の記事です)。
5. 静的メソッドを直接呼び出す場合
Subscribe
に直接静的メソッドPiyoMethod
を渡しています。
この場合も3の時と同様に、C#コンパイラはデリゲートを生成して包むという挙動を示すようです。
計測結果
結果としては、
- 1の場合が
104B
と最もアロケーションが小さい -
SubscribeWithState
を使った4の場合が8B増加して112B
と2番目に小さい - ローカル変数や外部のメソッドを使用した2, 3, 5の場合は確保されたメモリが
232B
と1, 4の場合と比較して2倍以上
のようになり、概ね予想通りの順番になりました。
特にアロケーションを抑制するために用意されたSubscribeWithState
を使うだけで確保されるメモリ領域を半分以下に減らせるのはとても便利なので、今後も積極的に使っていきたいと思います。
また、ついでに測定したOnNext
時のアロケーションですが、以下のような結果で、今回のケースではどのパターンにおいてもアロケーションは発生しませんでした。
なお、今回のテストコードを用いた計測は実行環境などによって異なる結果となる場合がありますのでご了承下さい。