Unityでのパフォーマンス計測のための自作Profilerの紹介

# はじめに
本記事では複数のタスクのプロファイルの結果を比較して確認するためのツールを紹介します。

https://qiita.com/uminohiyoko/items/1dfeae592402697be025

や

https://qiita.com/uminohiyoko/items/af7c9925995c2968a640

の記事で使ったものですが、コードをそのまま貼るには長すぎたので記事を分けました。

## プロファイラの機能
タスクを計測して、Unityの`IMGUI`でそのままレポート結果を画面にオーバーレイして表示します。

![MainTable.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/606045/c06b955e-b98f-50d4-8389-6d642f1c97cc.jpeg)
![スクリーンショット 2023-03-25 201343.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/606045/bfc6edad-273c-0c53-8d3c-2e18d39d081f.jpeg)

上の画像のように、いろいろな処理のパフォーマンスを実行して比較できます。

特徴を列挙すると以下のような感じです。
- 複数のタスクを順番に自動で実行し、完了したものから結果を画面にオーバーレイで表示する。
- それぞれのタスク終了時に `GC.Collect()` を行う
- 特定の平均FPSになるまで探索的に処理を計測できる
- 2つのタスクを並べて処理速度を比較して結果を色分けして表示できる
- `IProfilableTask`と`IReport`を実装することで計測内容や出力方法を適宜カスタマイズ可能

## 使い方
任意の `MonoBehaviour` 内で `MultiProfilerHandler` を生成し、`OnGUI` 関数と `Update` 関数でそれぞれ
`_profilerHandler.OnGUI();` と `_profilerHandler.Update();` を呼び出します。計測タスクの定義方法は以下の使用例などを参考にしてみて下さい。


### 使用例
```MyProfiler.cs
using System;
using UnityEngine;
using IMGUIProfiler;


public class MyProfiler : MonoBehaviour
{
    private MultiProfilerHandler _profilerHandler;

    private void Start()
    {
        Application.targetFrameRate = 60; // 目標FPSを60に設定
        
        // EXP1
        _profilerHandler = new MultiProfilerHandler(
            new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum => 
            {
                for (int i = 0; i < loopNum; ++i)
                {
                    _ = i / 123456;
                } 
            }), "割り算"), 
            new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
            {
                for (int i = 0; i < loopNum; ++i)
                {
                    new System.Object();
                }
            }), "Object型の生成"));
    }

    private void OnGUI()
    {
        _profilerHandler.OnGUI();
    }

    private void Update()
    {
        _profilerHandler.Update();
    }
}
```
こちらは「割り算」と「オブジェクトの生成」の2つの処理しの所要時間を比較するコードです。
実行すると、以下の画像のような出力が得られます。

![スクリーンショット 2023-03-25 234414.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/606045/743dcd7a-cec9-7076-18de-ac6ded53c2d4.jpeg)

また、最初に貼った2枚の画像のコードはそれぞれの記事をご参照下さい。

### コード
プロファイラを使用するには以下のファイルをプロジェクトに追加し、利用するファイルにて `using IMGUIProfiler` でインポートします。

<details><summary>コード全文</summary><div>

``` IMGUIProfiler.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using UnityEngine;

namespace IMGUIProfiler
{
    public interface IDisplayableReport
    {
        public void OnGUI();
    }

    public interface IRequestableFinishProfiler
    {
        public bool ShouldProfilerFinish();
    }
        
    public interface IReport
    {
        string title { get; }
        string text { get; }
    }

    public interface IProfileableTask
    {
        void Update();
        bool isFinished { get; }
        IReport report { get; }
    }

    public class FPSReport : IReport, IDisplayableReport, IRequestableFinishProfiler
    {
        public string title { get; }
        public string text => $"平均FPS: {fps}";
        public float fps { get; }
        
        public FPSReport(string title, float fps)
        {
            this.title = title;
            this.fps = fps;
        }

        public void OnGUI()
        {
            var originalColor = GUI.color;
            GUI.color = fps < 55 ? new Color(1, 0.5f + fps/40, fps/80) : originalColor; // グラデーション表示
            GUILayout.Box($"{title}\n{text}");
            GUI.color = originalColor;
        }

        public bool ShouldProfilerFinish()
        {
            return fps < 15; // 15FPS未満になったら以降は打ち切り
            // return fps < 45; // これでも十分
        }
    }

    public class FPSProfileTask : IProfileableTask
    {
        /* Config */
        public static int ProfileFrameNum = 200;
        
        /* public properties */
        private string name { get; }
        public IReport report { get; private set; }
        public bool isFinished => _frameCounter >= ProfileFrameNum;
        
        private readonly Action _task;
        private float _accumTimeDelta = 0;
        private int _frameCounter = 0;

        public FPSProfileTask(double loopNum, Action task)
        {
            this.name = $"{loopNum:N0} loops";
            this._task = task;
        }

        public void Update()
        {
            if (isFinished) return;

            _task.Invoke();
            CheckFPS();
        }
        
        private void CheckFPS()
        {
            _accumTimeDelta += Time.deltaTime;
            _frameCounter++;
            if (isFinished)
            {
                report = new FPSReport(name, _frameCounter / _accumTimeDelta);
            }
        }
    }

    public class TimeReport : IReport
    {
        public string title { get; }
        public string text { get; }
        public double time { get; }

        public TimeReport(double time, string title)
        {
            this.time = time;
            this.title = title;
            this.text = $"計測時間: {time}[ms]";
        }
    }

    public class MeasureTimeTask : IProfileableTask
    {
        static readonly Stopwatch _sw = new Stopwatch();
        
        public bool isFinished { get; private set; } = false;
        public IReport report { get; private set; }
        private string name { get; }
        private readonly Action _task;
        private double _measuredTime = 0;

        public MeasureTimeTask(Action task, string name)
        {
            this.name = name;
            _task = task;
        }
        
        public void Update()
        {
            StartTask();
        }

        private void StartTask()
        {
            isFinished = true;
            _sw.Reset();
            _sw.Start();
            _task.Invoke();
            _sw.Stop();
            _measuredTime = (double) _sw.ElapsedTicks / (Stopwatch.Frequency / 1000);

            this.report = new TimeReport(_measuredTime, name);
        }
    }

    public class CompareTimeTask : IProfileableTask
    {
        private class CompareTimeReport : IReport, IDisplayableReport
        {
            public string title { get; }
            public string text => "";

            private TimeReport _report1;
            private TimeReport _report2;

            private GUIStyle tintableText;

            public void OnGUI()
            {
                var text = $"{title}\n" +
                           $"{_report1.title}: {_report1.time} [ms]\n" +
                           $"{_report2.title}: {_report2.time} [ms]";

                var color = _report1.time > _report2.time
                    ? new Color(1f, 0f, 0f, 0.4f)
                    : _report1.time == _report2.time
                        ? new Color(0f, 0.8f, 0f, 0.2f)
                        : new Color(0f, 0f, 1f, 0.4f);
                ColoredBoxHelper.ColoredBox(text, color);
            }

            public CompareTimeReport(TimeReport report1, TimeReport report2)
            {
                this.title = $"計測時間の比較";
                _report1 = report1;
                _report2 = report2;
            }
        }



        public bool isFinished => report != null;
        public IReport report { get; private set; }
        public string name => "";
        private readonly MeasureTimeTask _task1;
        private readonly MeasureTimeTask _task2;

        public CompareTimeTask(MeasureTimeTask task1, MeasureTimeTask task2)
        {
            _task1 = task1;
            _task2 = task2;
        }
        
        public void Update()
        {
            _task1.Update();
            _task2.Update();
            report = new CompareTimeReport(_task1.report as TimeReport, _task2.report as TimeReport);
        }
    }

    public class ShowDescriptionTask : IProfileableTask
    {
        private class DummyDescriptionReport : IReport, IDisplayableReport
        {
            public string title => "";
            public string text { get; }
            public void OnGUI()
            {
                GUILayout.Box($"{text}");
            }

            public DummyDescriptionReport(string text)
            {
                this.text = text;
            }
        } 
        public void Update()
        {
            isFinished = true;
        }

        public bool isFinished { get; private set; }
        public IReport report { get; }
        public string name => "";

        public ShowDescriptionTask(string text)
        {
            report = new DummyDescriptionReport(text);
        }
    }

    public class MultiTaskProfiler
    {
        public readonly struct Progress
        {
            public int numFinished { get; }
            public int numRemain { get; }
            public int numTotal => numFinished + numRemain;
            public float progress => (float) numFinished / numTotal;
            
            public Progress(int numFinished, int numRemain)
            {
                this.numFinished = numFinished;
                this.numRemain = numRemain;
            }
        }
        /* internal states */
        protected Queue<IProfileableTask> _taskQueue;
        private List<IReport> _reports = new List<IReport>();
        private bool _restFlag = true;

        /* public properties */
        public Progress progress => new (_reports.Count, _taskQueue.Count);
        public bool IsFinished { get; private set; } = false;
        public List<IReport> reports => _reports;

        public string profilerName { get; }
        public Action OnFinished = null;

        public MultiTaskProfiler(IEnumerable<IProfileableTask> tasks, string profilerName = "", Action onFinished = null)
        {
            _taskQueue = new Queue<IProfileableTask>(tasks);
            this.profilerName = profilerName;
            this.OnFinished += onFinished;
        }

        public void Update()
        {
            if (IsFinished) return;
            if (_restFlag)
            {
                _restFlag = false;
                return; // 前のタスクなどの影響を避けるため、最初の1フレームはスキップする
            }

            var task = _taskQueue.Peek();
            task.Update();
            if (task.isFinished)
            {
                OnFinishTask(task);
            }
        }
        
        private void OnFinishTask(IProfileableTask task)
        {
            _restFlag = true;
            _reports.Add(task.report);
            _taskQueue.Dequeue();
            if (task.report is IRequestableFinishProfiler requestReport)
            {
                // 打ち切り条件を満たしていたら以降のタスクは打ち切る
                if (requestReport.ShouldProfilerFinish())
                {
                    _taskQueue.Clear();
                }
            }
            OnFinished?.Invoke();
            GC.Collect();
            if (!_taskQueue.Any())
            {
                IsFinished = true;
            }
        } 

        public void ShowOnGUI()
        {
            using (new GUILayout.VerticalScope())
            {
                if (!string.IsNullOrEmpty(profilerName))
                {
                    GUILayout.Label($"【{profilerName}】");
                }
                var text = IsFinished
                    ? $"計測完了!!: {progress.numFinished}/{progress.numTotal}"
                    : $"計測中。。。: {progress.numFinished}/{progress.numTotal}";

                var backgroundColor = GUI.backgroundColor;
                GUI.color = IsFinished ? Color.green : backgroundColor;
                GUILayout.Box(text);
                GUI.color = backgroundColor;
                foreach (var report in reports)
                {
                    if (report is IDisplayableReport customDisplayableReport)
                    {
                        customDisplayableReport.OnGUI();
                    }
                    else
                    {
                        GUILayout.Box($"{report.title}\n{report.text}");
                    }
                }
            }
        }
    }


    public class MultiProfilerHandler
    {
        private readonly IReadOnlyList<MultiTaskProfiler> _profilers;

        public MultiProfilerHandler(params MultiTaskProfiler[] profilers)
        {
            _profilers = profilers;
        }

        public MultiProfilerHandler(IEnumerable<MultiTaskProfiler> profilers) : this(profilers.ToArray()) { }

        public void OnGUI()
        {
            using (new GUILayout.HorizontalScope())
            {
                foreach (var profiler in _profilers)
                {
                    profiler.ShowOnGUI();
                }
            }
        }

        public void Update()
        {
            var unfinishedProfiler = _profilers.FirstOrDefault(profiler => !profiler.IsFinished);
            unfinishedProfiler?.Update();
        }
    }

    public static class ExperimentHelper
    {
        public static double[] loopNumList = new[] {5e4, 1e5, 2e5, 5e5, 1e6, 2e6, 5e6, 1e7, 2e7, 5e7};
        public static void SetLoopNum(double[] loopNumList_) { loopNumList = loopNumList_;}  

        public static IEnumerable<IProfileableTask> CreateLoopTasks(Action<double> loopTask)
        {
            foreach (var loopNum in loopNumList)
            {
                yield return new FPSProfileTask(loopNum, () =>
                {
                    loopTask.Invoke(loopNum);
                }); 
            }
        }
    }
}
```

</div></details>

#### 依存ファイル
IMGUIの`Box`の色付けのために

https://qiita.com/uminohiyoko/items/0959a0befc776a5f4e67

に紹介した自作ファイル（クラス）を利用しています。