はじめに
Unity (C#) の各種の演算や処理を、何回までならフレーム落ちせずに実行できるか、限界ラインを計測してまとめてみました。
TL; DR (結果の早見表)
60FPSの頻度で実行した際にフレーム落ちが発生しない限界の回数
処理 | 60FPSで処理可能な回数 | コード |
---|---|---|
for ループ |
5,000,000 | for (int i=0; i<loopNum; ++i) |
foreach ループ (Array以外1) |
1,000,000 | foreach (int _ in loopObject); |
割り算 | 2,000,000 | _ = i / 123456; |
関数呼び出し | 1,000,000 | func.Invoke(); |
型判定 | 5,000,000 | myList is List<int>; |
ダウンキャスト | 5,000,000 | myList as List<int>; |
構造体の生成 | 5,000,000 | new MyStruct() { a = 5, b = 10 }; |
参照型 (クラス) の生成 | 200,000 | new Object(); |
ボクシング | 200,000 |
object _ = Num; (Numは int 型) |
内部で foreach (Array以外2) | 200,000 | foreach(var _ in myList) {} |
IEnumerable.Any() 3 | 100,000 | myList.Any(); |
大きなヒープ領域の確保 | 5,000 | _ = new int[1000]; |
GameObjectに対する操作
処理 | 60FPSで処理可能な回数 | コード |
---|---|---|
GameObject作成 (new) | 2,000 | new GameObject(); |
Instantiate() | 2,000 | Instantiate(emptyObject); |
Instantiate + Destory() | 1,000 | 上 + Destroy(clonedObj);
|
GetComponent | 50,000 | myObject.GetComponent<MyBehaviour>(); |
SendMessage | 20,000 | myObject.SendMessage("SayHello"); |
FindObjectOfType | 10,000 | FindObjectOfType<GameObject>(); |
0~999までのList<int>
に対してLINQの操作を実行した結果
処理 | 60FPSで処理可能な回数 | コード |
---|---|---|
Any | 100,000 | myList.Any(); |
Where | 50,000 | myList.Where(x => x > 500); |
Select | 50,000 | myList.Select(x => x * 500); |
ToArray | 5,000 | myList.ToArray(); |
ToList | 5,000 | myList.ToList(); |
ToHashSet | 200 | myList.ToHashSet(); |
ToLookup (key多め) | 20 | myList.ToLookup(num => num, num => num * num); |
ToLookup (key少なめ) | 100 | myList.ToLookup(num => num%10, num => num * num); |
GroupBy (group少なめ) | 100 | myList.GroupBy(num => num%10); |
- こちらは「1eN, 2eN, 5eN, ...」の区切りで、
55FPS
(1秒間に55回) 以上で処理できた最大のフレーム内処理回数を測定したものです- 言い換えると、
MonoBehaviour
のUpdate()
内で処理可能な限界数です
- 言い換えると、
- 「forループ」「foreachループ」以外の項目は、forループ内で処理しているため、「forループ」の分の処理負荷4を含みます。
基礎知識
Unityのスクリプトは基本的にメインスレッドで実行されます。CPUのクロック数4GHzとすると、1秒間に40億回 、60FPSのゲームであれば1フレーム当たり6666万回 のCPUレベルでの演算が行われます。演算の種類や処理系、コンパイラによる最適化の有無などによって、実際にCPUで行われる演算量は変わります。演算のコストをすごくざっくりと分類すると、以下のような3段階に分けられます。
処理 | 処理コストの大きさ | 補足 |
---|---|---|
比較・算術演算・型判定・値型の生成・関数呼び出し | 小 | リテラルのstring 型もこちら |
クラス(参照型)の生成、ヒープ領域の確保 | 中 | GC管理のため重い |
GameObjectの作成や破棄 | 大 | Unity管理のため重い |
C#では、「クラス(参照型)の生成」が暗黙的に行われるケースが非常に多いので、注意が必要です。
処理落ちラインを計測する
計測方法
単純にコードの処理時間を計測するとGC負荷のコストなどが正確に評価できないので、反復処理を行った時のFPSの実測値を計測しました。
Application.targetFrameRate
を 60
に設定した上で、実際に 200フレーム
処理した際の平均FPSを、単位フレームあたりの処理回数を増加させながら計測しました。
各処理のループ回数は 20, 50, 100, 200, 500, ...etc と刻みながら増加させて計測し、15FPSを下回った時点でストップしています。また、実測FPSが55以上だった最大のループ数
を処理落ちしないラインとして採用しています。
実行環境
Windows11, Unity Editorの Play Modeで実行。
Unityのバージョン: 2022.2.4f1 (Roslyn, C#9.0)
CPU: Intel® Core™ i9-9900KF (クロック数: 3.6GHz. 最大5.0GHz)
コード
計測には以下のコードを使用しました。長いのでコードの詳細は割愛しますが、各テストの単位は以下のようなコードで作成しています。
関数呼び出しのオーバーヘッドを避けるため、計測する処理はfor文と共に書き下しています。
yield return new FPSProfileTask(loopNum, () =>
{
for (int i = 0; i < loopNum; ++i)
{
_ = i / 123456; // ここで各項目の処理を行います(こちらは「割り算」の例)
}
});
コードの全文は以下になります。
【追記 2023/03/25】
さすがにコードが長いので、Profiler の定義は別ファイルにして、Profilerの説明は以下の記事に分けました。
コード全文
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using IMGUIProfiler;
public class FPSProfiler : MonoBehaviour
{
private MultiProfilerHandler _profilerHandler;
private void Start()
{
Application.targetFrameRate = 60; // 目標FPSを60に設定
// // EXP1
_profilerHandler = new MultiProfilerHandler(GetExp1Profilers(new double[] {5e4, 1e5, 2e5, 5e5, 1e6, 2e6, 5e6, 1e7, 2e7, 5e7}));
// EXP2
// _profilerHandler = new MultiProfilerHandler(GetExp2Profilers(new [] {1e2, 2e2, 5e2, 1e3, 2e3, 5e3, 1e4, 2e4, 5e4, 1e5, 2e5, 5e5, 1e6, 2e6, 5e6, 1e7, 2e7, 5e7}));
}
private void OnGUI()
{
_profilerHandler.OnGUI();
}
private void Update()
{
_profilerHandler.Update();
}
struct MyStruct
{
public int a;
public double b;
}
// contents of EXP1
private IEnumerable<MultiTaskProfiler> GetExp1Profilers(IEnumerable<double> loopNumList)
{
ExperimentHelper.SetLoopNum(loopNumList.ToArray());
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
for (int i = 0; i < loopNum; ++i)
{
/* do nothing */
}
}), "forループ");
yield return new MultiTaskProfiler(
loopNumList.Select(loopNum =>
{
var loopObject = Enumerable.Repeat(0, (int) loopNum).ToList(); // O(N) なのでタスクの外で生成
return new FPSProfileTask(loopNum, () =>
{
foreach(int _ in loopObject) {}
});
}), "foreach ループ");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
for (int i = 0; i < loopNum; ++i)
{
_ = i / 123456;
}
}), "割り算");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
Action emptyFunc = () => { };
for (int i = 0; i < loopNum; ++i)
{
emptyFunc.Invoke();
}
}), "関数呼び出し");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
IReadOnlyList<int> myList = new List<int>();
for (int i = 0; i < loopNum; ++i)
{
_ = myList is List<int>;
}
}), "型判定");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
IReadOnlyList<int> myList = new List<int>();
for (int i = 0; i < loopNum; ++i)
{
List<int> list = myList as List<int>;
}
}), "ダウンキャスト");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
for (int i = 0; i < loopNum; ++i)
{
new MyStruct() {a = 5, b = 10};
}
}), "構造体の生成");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
for (int i = 0; i < loopNum; ++i)
{
new System.Object();
}
}), "Object型の生成");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
const int myNumber = 5;
for (int i = 0; i < loopNum; ++i)
{
object _ = myNumber;
}
}), "ボクシング");
}
// contents of EXP2
public class MyBehaviour : MonoBehaviour
{
private void SayHello() { /* No Hello because of performance reason...*/ }
}
private IEnumerable<MultiTaskProfiler> GetExp2Profilers(IEnumerable<double> loopNumList)
{
ExperimentHelper.SetLoopNum(loopNumList.ToArray());
// FPSProfileTask.ProfileFrameNum = 20; // For Debug
FPSProfileTask.ProfileFrameNum = 60; // For Debug
void RemoveGarbage()
{
var garbageObjects = FindObjectsOfType<GameObject>();
foreach (var garbage in garbageObjects)
{
if (garbage.name != "Main Camera" &&
garbage.name != "Directional Light" &&
garbage.name != "ProfilerObject")
{
DestroyImmediate(garbage);
}
}
}
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
for (int i = 0; i < loopNum; ++i)
{
_ = new GameObject();
}
}), "new GameObject", RemoveGarbage);
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
var emptyObject = new GameObject();
for (int i = 0; i < loopNum; ++i)
{
Instantiate(emptyObject);
}
}), "Instantiate", RemoveGarbage);
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
var emptyObject = new GameObject();
for (int i = 0; i < loopNum; ++i)
{
var obj = Instantiate(emptyObject);
Destroy(obj);
}
}), "Instantiate + Destroy", RemoveGarbage);
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
for (int i = 0; i < loopNum; ++i)
{
FindObjectOfType<GameObject>();
}
}), "FindObjectOfType");
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
var myObject = new GameObject();
myObject.AddComponent<MyBehaviour>();
for (int i = 0; i < loopNum; ++i)
{
myObject.SendMessage("SayHello");
}
}), "SendMessage", RemoveGarbage);
yield return new MultiTaskProfiler(ExperimentHelper.CreateLoopTasks(loopNum =>
{
var myObject = new GameObject();
myObject.AddComponent<MyBehaviour>();
for (int i = 0; i < loopNum; ++i)
{
myObject.GetComponent<MyBehaviour>();
}
}), "GetComponent");
}
}
実験1: C#の各種処理
60FPS (1秒間に60回) の頻度で実行した際にフレーム落ちが発生しない限界の回数
処理 | 60FPSで処理可能な回数 | コード |
---|---|---|
for ループ | 5,000,000 | for (int i=0; i<loopNum; ++i) |
foreach ループ (Array以外1) | 1,000,000 | foreach (int _ in loopObject); |
割り算 | 2,000,000 | _ = i / 123456; |
関数呼び出し | 1,000,000 | func.Invoke(); |
型判定 | 5,000,000 | myList is List<int>; |
ダウンキャスト | 5,000,000 | myList as List<int>; |
構造体の生成 | 5,000,000 | new MyStruct() { a = 5, b = 10 }; |
参照型 (クラス) の生成 | 200,000 | new Object(); |
ボクシング | 200,000 |
object _ = Num; (Numは int 型) |
IDisposable | 200,000 | using (new MyDisposable()) {}; |
内部で foreach (Array以外2) | 200,000 | foreach(var _ in myList) {} |
IEnumerable.Any() 3 | 100,000 | myList.Any(); |
大きなヒープ領域の確保 | 5,000 | _ = new int[1000]; |
スクリプトを実行すると画像の結果が得られました。処理落ちラインをまとめると表のようになりました。
「割り算」以降はfor
ループ自体の処理コストが含まれまれることにご注意下さい。
概要
- Object型(参照型・クラス)の生成コストが一際大きい
- ボクシング,
foreach
の呼び出し,Any
などはこのコストがかかっているため重い
- ボクシング,
- 関数呼び出しや、
foreach
ループのコストは比較的大きい-
foreach
ループがfor
ループと比較して遅いのは内部で関数呼び出しを行っているため1
-
- ヒープ領域の確保は、領域のサイズ依存だがかなりコストが大きい
- 型判定やダウンキャストのコストは小さい
実験2: GameObjectの処理
60FPS (1秒間に60回) の頻度で実行した際にフレーム落ちが発生しない限界の回数
処理 | 60FPSで処理可能な回数 | コード |
---|---|---|
GameObject作成 (new) | 2,000 | new GameObject(); |
Instantiate() | 2,000 | Instantiate(emptyObject); |
Instantiate + Destory() | 1,000 | 上 + Destroy(clonedObj);
|
GetComponent | 50,000 | myObject.GetComponent<MyBehaviour>(); |
SendMessage | 20,000 | myObject.SendMessage("SayHello"); |
FindObjectOfType | 10,000 | FindObjectOfType<GameObject>(); |
実験方法の詳細
デフォルトの3D SceneにProfiler用のGameObject
のみ追加した状態で計測しました。
Instantiate
は空のGameObject
に対して実行しました。
GetComponent
とSenedMessage
で使用しているmyObject
は、空のGameObject
に次のMonoBehaviourを追加したものです。
public class MyBehaviour : MonoBehaviour
{
private void SayHello() { /* 空のメソッド */ }
}
エディタ上での実行のため、Hierarchyウィンドウはオーバーヘッドの回避のため閉じて実行しています。
追加変更内容
(2023/03/26) 計測タスク単位でGameObject
を掃除するようにスクリプトを修正しました。表の結果には影響はありません。
(2023/03/26) GetComponent, SendMessage, FindObjectOfTypeを追加しました
概要
- GameObjectは
new
,Instantiate()
,Destroy()
などのオブジェクトの生成や破棄の処理は、いずれも非常に大きな処理コストがかかる。 - 生成や破棄ほどではないが、
GetComponent
、SendMessage
、FindObjectOfType
などのUnityオブジェクトに対する各種メソッドは、比較的大きな処理コストがかかる。
(参考)
SendMessage
、BroadcastMessage
は直接の関数呼び出しに比べて1000倍単位で遅くなる場合があるとMicrosoftの公式ドキュメント内の記事で警告されています。
実験3: LINQの処理
サイズ1000のList<int>
に対してLINQの操作を実行した結果
処理 | 60FPSで処理可能な回数 | コード |
---|---|---|
Object型の生成 (参考) | 200,000 | new Object(); |
Any | 100,000 | myList.Any(); |
Where | 50,000 | myList.Where(x => x > 500); |
Select | 50,000 | myList.Select(x => x * 500); |
ToArray | 5,000 | myList.ToArray(); |
ToList | 5,000 | myList.ToList(); |
ToHashSet | 200 | myList.ToHashSet(); |
ToLookup (key多め) | 20 | myList.ToLookup(num => num, num => num * num); |
ToLookup (key少なめ) | 100 | myList.ToLookup(num => num%10, num => num * num); |
GroupBy (group少なめ) | 100 | myList.GroupBy(num => num%10); |
LINQの各操作について調査した結果がこちらです。LINQの処理時間は大体 IEnumerable
オブジェクトの要素数に依存しますが、ここでは0から999までの値を持つList<int>
オブジェクトに対して処理を行って計測しました。Where, Select, GroupBy は、遅延評価を解決するためにAny()
を呼び出しています。
概要
- LINQ の基本コストとして、Enumeratorに起因する Objectの生成が1回または2回発生する (Listの場合はEnumeratorがstructでボクシングするため2回)
-
Where
などをチェインして何度も呼ぶ場合はその分だけコストがかかる
-
-
Where
,Select
,ToArray
,ToList
はコレクションのサイズに応じてO(n)
のコストがかかる-
ToArray
とToList
は配列のコピー時にヒープ領域を確保するので特にコストが重い。
-
-
ToHashSet
も基本的にO(n)
だが、ArrayやListを作るよりはだいぶ重い。5 -
ToLookup
はKeyごとにオブジェクトを生成するので、Keyが多いと処理が重くなる -
GroupBy
もグループごとにオブジェクトを生成するので、グループが多いと処理が重くなる
-
Array
のみ内部でEnumeratorを利用しないため速いようです。それ以外は内部でenumerator.MoveNext()
を呼び出してループするので、関数呼び出しの分コストがかかっているようです。 ↩ ↩2 ↩3 -
Array
のみオブジェクトの生成を回避できます。List
やIEnumerable
の場合はEnumeratorの生成コストがかかります。List
のEnumeratorはstructなのですが、内部の後処理でIDisposable
にキャストする際にボクシングするようです。 ↩ ↩2 -
LINQの呼び出しの際にEnumerator起因のアロケーションが1~2回 (Enumeratorの実装がstructならボクシングのため2回) 発生します。型が
Array
やList
でも同様です。Any
の場合はarray.Length > 0
やlist.Count > 0
に置き換えることで回避できます。 ↩ ↩2 -
for
文の式に含まれるインクリメントや比較演算のコストです。 ↩ -
List
の生成などと比べてここまで重い理由はよく分かっていません。HashSet
は内部(のヒープ領域)にstructを大量に持つので、これが重いのかなと個人的に予想しています ↩