第10章 Tuning Practice - Script (C#)

本章では、主にC#コードのパフォーマンスチューニング手法について実例を交えながら紹介します。C#の基礎的な記法についてはここでは扱わず、パフォーマンスを要求されるようなゲームの開発において意識すべき設計や実装について解説します。

10.1 GC.Allocするケースと対処法

「2.5.2 ガベージコレクション」で紹介しましたが、本節では具体的にどのような処理を行ったときにGC.Allocをするのか、まずは理解していきましょう。

10.1.1 参照型のnew

まずはとてもわかりやすくGC.Allocが発生するケースです。

リスト10.1: 毎フレームGC.Allocするコード

 1: private void Update()
 2: {
 3:     const int listCapacity = 100;
 4:     // List<int>のnewでGC.Alloc
 5:     var list = new List<int>(listCapacity);
 6:     for (var index = 0; index < listCapacity; index++)
 7:     {
 8:         // 特に意味はないがindexをListに詰める
 9:         list.Add(index);
10:     }
11:     // listから値をランダムに取り出す
12:     var random = UnityEngine.Random.Range(0, listCapacity);
13:     var randomValue = list[random];
14:     // ... ランダムな値から何かする ...
15: }

このコードの大きな問題点は、毎フレーム実行されるUpdateメソッドで、List<int>newしている点です。

こちらを修正するには、List<int>を事前に生成して使い回すことで毎フレームのGC.Allocを回避することが可能です。

リスト10.2: 毎フレームのGC.Allocを無くしたコード

 1: private static readonly int listCapacity = 100;
 2: // 事前にListを生成しておく
 3: private readonly List<int> _list = new List<int>(listCapacity);
 4: 
 5: private void Update()
 6: {
 7:     _list.Clear();
 8:     for (var index = 0; index < listCapacity; index++)
 9:     {
10:         // 特に意味はないけどindexをListに詰めていく
11:         _list.Add(index);
12:     }
13:     // listから値をランダムに取り出す
14:     var random = UnityEngine.Random.Range(0, listCapacity);
15:     var randomValue = _list[random];
16:     // ... ランダムな値から何かする ...
17: }

こちらのサンプルコードのような無意味なコードを書くことはないと思いますが、類似した例は想像よりも見つかるケースが多いです。

GC.Allocを無くしたら

気付いた方も多いかと思いますが、上記リスト10.2のサンプルコードはこれだけで事足ります。

リスト10.3:

 1: var randomValue = UnityEngine.Random.Range(0, listCapacity);
 2: // ... ランダムな値から何かする ...

パフォーマンスチューニングにおいてGC.Allocを無くすことを考えるのは重要ですが、無意味な計算を省くことを常に考えることが高速化の一歩に繋がります。

10.1.2 ラムダ式

ラムダ式も便利な機能ですが、こちらも使い方によってはGC.Allocが発生してしまうため、ゲームでは使用できる場面は限られます。ここでは、次のようなコードが定義されている前提とします。

リスト10.4: ラムダ式サンプルの前提コード

 1: // メンバー変数
 2: private int _memberCount = 0;
 3: 
 4: // static変数
 5: private static int _staticCount = 0;
 6: 
 7: // メンバーメソッド
 8: private void IncrementMemberCount()
 9: {
10:     _memberCount++;
11: }
12: 
13: // staticメソッド
14: private static void IncrementStaticCount()
15: {
16:     _staticCount++;
17: }
18: 
19: // 受け取ったActionをInvokeするだけのメンバーメソッド
20: private void InvokeActionMethod(System.Action action)
21: {
22:     action.Invoke();
23: }

このとき、次のようにラムダ式内で変数を参照した場合、GC.Allocが発生します。

リスト10.5: ラムダ式内で変数を参照してGC.Allocするケース

 1: // メンバー変数を参照した場合、Delegate Allocationが発生
 2: InvokeActionMethod(() => { _memberCount++; });
 3: 
 4: // ローカル変数を参照した場合、Closure Allocationが発生
 5: int count = 0;
 6: // 上記と同じDelegate Allocationも発生
 7: InvokeActionMethod(() => { count++; });

ただし、以下のようにstatic変数を参照すると、これらのGC.Allocを回避できます。

リスト10.6: ラムダ式内でstatic変数を参照してGC.Allocしないケース

 1: // static変数を参照した場合、GC Allocは発生せず
 2: InvokeActionMethod(() => { _staticCount++; });

ラムダ式内でのメソッド参照も記述方法によってGC.Allocのされ方が異なります。

リスト10.7: ラムダ式内でメソッドを参照してGC.Allocするケース

 1: // メンバーメソッドを参照した場合、Delegate Allocationが発生
 2: InvokeActionMethod(() => { IncrementMemberCount(); });
 3: 
 4: // メンバーメソッドを直接指定した場合、Delegate Allocationが発生
 5: InvokeActionMethod(IncrementMemberCount);
 6: 
 7: // staticメソッドを直接指定した場合、Delegate Allocationが発生
 8: InvokeActionMethod(IncrementStaticCount);

これらを回避するためには、以下のようにステートメント形式でstaticメソッドを参照する必要があります。

リスト10.8: ラムダ式内でメソッドを参照してGC.Allocしないケース

 1: // ラムダ式内でstaticメソッドを参照した場合は、Non Allocとなる
 2: InvokeActionMethod(() => { IncrementStaticCount(); });

こうすることで初回のみActionがnewされますが、内部的にキャッシュされることによって2回目以降GC.Allocが回避されます。

しかし、すべての変数やメソッドをstaticにすることはコードの安全性や可読性の面からとても採用できるものではありません。高速化が必要なコードでは、staticを多用してGC.Allocを無くすよりも毎フレームもしくは不定なタイミングで発火するイベントなどはラムダ式を使わずに設計する方が安全と言えるでしょう。

10.1.3 ジェネリックを使用してボックス化するケース

ジェネリックを使用した以下の場合、何が原因でボックス化する可能性があるでしょうか。

リスト10.9: ジェネリックを使用してボックス化する可能性がある例

 1: public readonly struct GenericStruct<T> : IEquatable<T>
 2: {
 3:     private readonly T _value;
 4: 
 5:     public GenericStruct(T value)
 6:     {
 7:         _value = value;
 8:     }
 9: 
10:     public bool Equals(T other)
11:     {
12:         var result = _value.Equals(other);
13:         return result;
14:     }
15: }

このケースでは、プログラマーはGenericStructIEquatable<T>インターフェイスの実装をしましたが、Tに制限を設けるのを忘れてしまいました。その結果、IEquatable<T>インターフェイスの実装がされていない型をTに指定できてしまい、Object型へ暗黙的にキャストされて以下のEqualsが使われるケースが存在してしまいます。

リスト10.10: Object.cs

 1: public virtual bool Equals(object obj);

たとえばIEquatable<T>インターフェイスの実装がされていないstructTに指定すると、Equalsの引数でobjectにキャストされることになるため、ボックス化が発生します。これが起こらないように事前に対策するには、次のように変更します。

リスト10.11: ボックス化しないように制限をかけた例

 1: public readonly struct GenericOnlyStruct<T> : IEquatable<T>
 2:     where T : IEquatable<T>
 3: {
 4:     private readonly T _value;
 5: 
 6:     public GenericOnlyStruct(T value)
 7:     {
 8:         _value = value;
 9:     }
10: 
11:     public bool Equals(T other)
12:     {
13:         var result = _value.Equals(other);
14:         return result;
15:     }
16: }

where句(ジェネリック型制約)を用いて、Tが受け入れられる型をIEquatable<T>を実装している型に制限してあげることで、こうした予期せぬボックス化を未然に防ぐことができます。

本来の目的を見失わない

「2.5.2 ガベージコレクション」で紹介したようにゲームではランタイム中のGC.Allocを避けたい意図があるため、構造体が選択されるケースも多く存在します。ただし、GC.Allocを削減したいあまりにすべてを構造体にしたところで高速化できるわけではありません。

よくある失敗としては、GC.Allocを避ける目的で構造体を取り入れたところ、期待通りGCに関するコストが減ったものの、データサイズが大きいために値型のコピーコストがかかってしまい結果的に非効率な処理になってしまうようなケースが挙げられます。

また、これをさらに回避するためにメソッドの引数を参照渡しを利用することでコピーコストを削減する手法も存在します。結果的に高速化できる可能性はありますが、この場合は最初からクラスを選択し、インスタンスを事前に生成して使い回すような実装を検討すべきでしょう。GC.Allocを撲滅することが目的ではなく、あくまでも1フレームあたりの処理時間を短くすることが最終目的であることを忘れないようにしましょう。

10.2 for/foreachについて

「2.6 アルゴリズムと計算量」で紹介したようにループはデータ数に依存して時間がかかるようになります。また、一見同じような処理に見えるループもコードの書き方次第で効率が変わります。

ここでは、SharpLab*1を利用して、foreach/forを使用したListや配列の中身を1つずつ取得するだけのコードをILからC#にデコンパイルした結果を見てみましょう。

まずはforeachでループを回した場合を見てみましょう。Listへの値の追加などは省略してます。

リスト10.12: Listをforeachで回す例

 1: var list = new List<int>(128);
 2: foreach (var val in list)
 3: {
 4: }

リスト10.13: Listをforeachで回す例のデコンパイル結果

 1: List<int>.Enumerator enumerator = new List<int>(128).GetEnumerator();
 2: try
 3: {
 4:     while (enumerator.MoveNext())
 5:     {
 6:         int current = enumerator.Current;
 7:     }
 8: }
 9: finally
10: {
11:     ((IDisposable)enumerator).Dispose();
12: }

foreachで回した場合は、列挙子を取得してMoveNext()で次へ進めてCurrentで値を参照する実装になっていることがわかります。さらに、list.cs*2MoveNext()の実装を見ると、サイズのチェックなど各種プロパティアクセス回数が多くなっており、インデクサーによる直アクセスより処理が多くなるように見えます。

[*2] https://referencesource.microsoft.com/#mscorlib/system/collections/generic/list.cs

次に、forで回したときを見てみましょう。

リスト10.14: Listをforで回す例

 1: var list = new List<int>(128);
 2: for (var i = 0; i < list.Count; i++)
 3: {
 4:     var val = list[i];
 5: }

リスト10.15: Listをforで回した際のデコンパイル結果

 1: List<int> list = new List<int>(128);
 2: int num = 0;
 3: while (num < list.Count)
 4: {
 5:     int num2 = list[num];
 6:     num++;
 7: }

C#ではfor文はwhile文の糖衣構文であり、インデクサー(public T this[int index])による参照で取得されていることがわかります。また、このwhile文をよく見ると、条件式にlist.Countが入っています。つまり、Countプロパティへのアクセスがループを繰り返すたびに行われることになります。Countの数が多くなればなるほど、Countプロパティへのアクセス回数が比例して増加し、数によっては無視できない負荷になっていきます。もし、ループ内でCountが変わらないのであれば、ループの前でキャッシュしておくことでプロパティアクセスの負荷を削減できます。

リスト10.16: Listをforで回す例: 改良版

 1: var count = list.Count;
 2: for (var i = 0; i < count; i++)
 3: {
 4:     var val = list[i];
 5: }

リスト10.17: Listをforで回す例: 改良版のデコンパイル結果

 1: List<int> list = new List<int>(128);
 2: int count = list.Count;
 3: int num = 0;
 4: while (num < count)
 5: {
 6:     int num2 = list[num];
 7:     num++;
 8: }

Countをキャッシュすることでプロパティアクセス回数が削減され、高速化されました。今回のループ中の比較はどちらもGC.Allocによる負荷はなく、実装内容の違いによる差分となります。

また、配列の場合はforeachも最適化されており、forで記述したものとほぼ変化はないものとなります。

リスト10.18: 配列をforeachで回す例

 1: var array = new int[128];
 2: foreach (var val in array)
 3: {
 4: }

リスト10.19: 配列をforeachで回す例のデコンパイル結果

 1: int[] array = new int[128];
 2: int num = 0;
 3: while (num < array.Length)
 4: {
 5:     int num2 = array[num];
 6:     num++;
 7: }

検証のため、データ数10,000,000として事前にランダムな数値をアサインし、List<int>データの和を計算するものとしました。検証環境はPixel 3a、Unity 2021.3.1f1で実施しました。

表10.1: List<int>における記述方法ごとの計測結果

種類Time ms
List: foreach66.43
List: for62.49
List: for(Countキャッシュ)55.11
配列: for30.53
配列: foreach23.75

List<int>の場合は、条件を細かく揃えて比較してみるとforeachよりもforCountの最適化を施したforのほうがさらに速くなったことがわかります。Listforeachは、Countの最適化を施したforに書き換えることで、foreachの処理におけるMoveNext()Currentプロパティのオーバーヘッドを削減し、高速化が可能です。

また、Listと配列のそれぞれ最速同士で比較すると、Listより配列のほうが約2.3倍以上高速になりました。foreachforでILが同じ結果になるように記述しても、foreachが速い結果となり、配列のforeachが十分に最適化されていると言えるでしょう。

以上の結果から、データ数が多くかつ処理速度を高速にしなければならない場面についてはList<T>ではなく配列を検討すべきでしょう。しかし、フィールドに定義したListをローカルキャッシュせずそのまま参照してしまうなど、書き換えが不十分な場合は高速化できないこともありますので、foreachからforに変更する際には必ず計測をしつつ適切に書き換えましょう。

10.3 オブジェクトプーリング

随所で触れてきましたが、ゲーム開発では動的にオブジェクトを生成せずに、事前生成して使い回すことが重要です。これをオブジェクトプーリングと呼びます。たとえば、ゲームフェーズで使用予定のオブジェクトをロードフェーズでまとめて生成してプーリングしておき、使用するときはプールしているオブジェクトへの代入と参照のみ行いながら扱うことで、ゲームフェーズ中のGC.Allocを避けることが可能です。

また、オブジェクトプーリングはアロケーションの削減の他にも、画面を構成するオブジェクトを都度作り直すことなく画面遷移を可能にしておくことでロード時間の短縮を実現したり、計算コストが非常に高い処理の結果を保持しておいて重い計算を複数回実行することを避けたり、さまざまな場面で用いられます。

ここでは広義にオブジェクトと表現しましたが、これは最小単位のデータに留まらず、CoroutineActionなどにも該当します。たとえば、事前にCoroutineを想定される実行数分以上生成しておき、必要なタイミングで使用して使い潰していくようなことも検討しましょう。2分間で終わるゲームで最大で20回実行されるようなときはIEnumeratorをそれぞれ先に生成しておき、使用する時はStartCoroutineするだけにすることで生成コストは抑えられます。

10.4 string

stringオブジェクトは文字列を表すSystem.Charオブジェクトのシーケンシャルコレクションです。stringは使い方1つでGC.Allocが簡単に起こります。たとえば、文字連結演算子+を利用して2つの文字列の連結をすると、新しいstringオブジェクトを生成することになります。stringの値は生成後に変更できない(イミュータブル)ため、値の変更が行われているように見える操作は新しいstringオブジェクトを生成して返しています。

リスト10.20: 文字列結合でstringを作る場合

 1: private string CreatePath()
 2: {
 3:     var path = "root";
 4:     path += "/";
 5:     path += "Hoge";
 6:     path += "/";
 7:     path += "Fuga";
 8:     return path;
 9: }

上記例の場合は、各文字列結合でstringが生成されていき、合計で164Byteアロケーションが発生します。

文字列が頻繁に変更されるときは、値が変更可能なStringBuilderを利用することでstringオブジェクトの大量生成を防ぐことができます。文字連結や削除などの操作をStringBuilderオブジェクトで行い、最終的に値を取り出してstringオブジェクトにToString()することで、取得時のみのメモリアロケーションに抑えることができます。また、StringBuilderを使用する際には、Capacityを必ず設定するようにしましょう。未指定のときは初期値が16になり、Appendなどで文字数が増えバッファーが拡張されるときにメモリの確保と値のコピーが走るため、不用意な拡張が発生しない適切なCapacityを設定するようにしましょう。

リスト10.21: StringBuilderでstringを作る場合

 1: private readonly StringBuilder _stringBuilder = new StringBuilder(16);
 2: private string CreatePathFromStringBuilder()
 3: {
 4:     _stringBuilder.Clear();
 5:     _stringBuilder.Append("root");
 6:     _stringBuilder.Append("/");
 7:     _stringBuilder.Append("Hoge");
 8:     _stringBuilder.Append("/");
 9:     _stringBuilder.Append("Fuga");
10:     return _stringBuilder.ToString();
11: }

StringBuilderを用いた例では、事前にStringBuilderを生成(上記例の場合、生成時に112Byteアロケーション)しておけば、以降は生成した文字列を取り出すToString()時に掛かる50Byteのアロケーションで済みます。

ただし、StringBuilderも値の操作中にアロケーションが起こりにくいだけで、前述のようにToString()実行時にはstringオブジェクトを生成することになるため、GC.Allocを避けたいときに使用するのは推奨されません。また、$""構文はstring.Formatに変換され、string.Formatの内部実装はStringBuilderが使われているため、結局はToString()のコストは避けられません。前項のオブジェクトの使い回しをここでも応用し、あらかじめ使用される可能性のある文字列はstringオブジェクトを事前に生成し、それを使うようにしましょう。

しかしながらゲーム中に文字列操作とstringオブジェクトの生成をどうしても行わなければならない場合もあります。そういう場合には、文字列用のバッファーを事前に持っておいて、そのバッファーをそのまま使えるようにするような拡張を行う必要があります。unsafeなコードを自前で実装するか、ZString*3のようなUnity向けの拡張機能(たとえばTextMeshProへのNonAllocな適用機能)を備えたライブラリの導入を検討しましょう。

10.5 LINQと遅延評価

本節ではLINQの使用によるGC.Allocを軽減する方法と遅延評価のポイントについて解説します。

10.5.1 LINQの使用によるGC.Allocを軽減する

LINQの使用では、リスト10.22のような場合にGC.Allocが発生します。

リスト10.22: GC.Allocが発生する例

 1: var oneToTen = Enumerable.Range(1, 11).ToArray();
 2: var query = oneToTen.Where(i => i % 2 == 0).Select(i => i * i);

リスト10.22でGC.Allocが発生する理由はLINQの内部実装に起因します。加えて、LINQの一部メソッドは呼び出し側の型に合わせた最適化を行うため、呼び出し元の型によってGC.Allocのサイズが変化します。

リスト10.23: 型ごとの実行速度検証

 1: private int[] array;
 2: private List<int> list;
 3: private IEnumerable<int> ienumerable;
 4: 
 5: public void GlobalSetup()
 6: {
 7:     array = Enumerable.Range(0, 1000).ToArray();
 8:     list = Enumerable.Range(0, 1000).ToList();
 9:     ienumerable = Enumerable.Range(0, 1000);
10: }
11: 
12: public void RunAsArray()
13: {
14:     var query = array.Where(i => i % 2 == 0);
15:     foreach (var i in query){}
16: }
17: 
18: public void RunAsList()
19: {
20:     var query = list.Where(i => i % 2 == 0);
21:     foreach (var i in query){}
22: }
23: 
24: public void RunAsIEnumerable()
25: {
26:     var query = ienumerable.Where(i => i % 2 == 0);
27:     foreach (var i in query){}
28: }

リスト10.23に定義した各メソッドのベンチマークを測定すると図10.1のような結果が得られました。この結果からT[]List<T>IEnumerable<T>の順番にヒープアロケーションのサイズが大きくなっていることがわかります。

このように、LINQを使用する場合は、実行時の型を意識することでGC.Allocのサイズを削減することができます。

型ごとの実行速度比較

図10.1: 型ごとの実行速度比較

LINQのGC.Allocの原因

LINQの使用によるGC.Allocの原因の一部は、LINQの内部実装です。LINQのメソッドはIEnumerable<T>を受け取り、IEnumerable<T>を返すものが多く、このAPI設計によりメソッドチェーンを用いた直感的な記述ができるようになっています。このときメソッドが返すIEnumerable<T>の実体は、各機能に合わせたクラスのインスタンスとなっています。LINQは内部的にIEnumerable<T>を実装したクラスをインスタンス化してしまい、さらにループ処理を実現するためにGetEnumerator()の呼び出しなどが行われるため内部的にGC.Allocが発生します。

10.5.2 LINQの遅延評価

LINQのWhereSelectといったメソッドは、実際に結果が必要になるまで評価を遅らせる遅延評価となっています。一方で、ToArrayのような即時評価となるメソッドも定義されています。

ここで、下記のリスト10.24のコードの場合を考えます。

リスト10.24: 即時評価を挟んだメソッド

 1: private static void LazyExpression()
 2: {
 3:     var array = Enumerable.Range(0, 5).ToArray();
 4:     var sw = Stopwatch.StartNew();
 5:     var query = array.Where(i => i % 2 == 0).Select(HeavyProcess).ToArray();
 6:     Console.WriteLine($"Query: {sw.ElapsedMilliseconds}");
 7: 
 8:     foreach (var i in query)
 9:     {
10:         Console.WriteLine($"diff: {sw.ElapsedMilliseconds}");
11:     }
12: }
13: 
14: private static int HeavyProcess(int x)
15: {
16:     Thread.Sleep(1000);
17:     return x;
18: }

リスト10.24の実行結果がリスト10.25になります。即時評価となるToArrayを末尾に追加したことで、queryへの代入時にWhereSelectのメソッドを実行し値を評価した結果が返されます。そのためHeavyProcessも呼び出されるので、queryを生成するタイミングで処理時間がかかっていることがわかります。

リスト10.25: 即時評価のメソッドを追加した結果

 1: Query: 3013
 2: diff: 3032
 3: diff: 3032
 4: diff: 3032

このようにLINQの即時評価のメソッドを意図せず呼び出してしまうとその箇所がボトルネックになってしまう可能性があります。ToArrayOrderBy, Countなどシーケンスすべてを一度見る必要があるメソッドは即時評価となるため、呼び出し時のコストを意識して使用しましょう。

10.5.3 「LINQの使用を避ける」という選択

LINQを使用した際のGC.Allocの原因や軽減方法、遅延評価のポイントについて解説しました。本節ではLINQを使用する基準について解説します。前提としてLINQは便利な言語機能ではありますが、使用するとヒープアロケーションや実行速度は使用しない場合に比べて悪化します。実際にMicrosoftのUnityのパフォーマンスに関する推奨事項*4では「Avoid use of LINQ」と明記されています。LINQを使用した場合と、使用しない場合で同じロジックを実装した場合のベンチマークをリスト10.26で比較してみます。

リスト10.26: LINQの使用有無によるパフォーマンス比較

 1: private int[] array;
 2: 
 3: public void GlobalSetup()
 4: {
 5:     array = Enumerable.Range(0, 100_000_000).ToArray();
 6: }
 7: 
 8: public void Pure()
 9: {
10:     foreach (var i in array)
11:     {
12:         if (i % 2 == 0)
13:         {
14:             var _ = i * i;
15:         }
16:     }
17: }
18: 
19: public void UseLinq()
20: {
21:     var query = array.Where(i => i % 2 == 0).Select(i => i * i);
22:     foreach (var i in query)
23:     {
24:     }
25: }

結果は図10.2になります。実行時間を比較するとLINQを使用しない場合に対してLINQを使った処理は19倍ほど時間がかかってしまっていることがわかります。

LINQの使用有無によるパフォーマンス比較結果

図10.2: LINQの使用有無によるパフォーマンス比較結果

上記の結果からLINQを使用することによるパフォーマンスの悪化は明確ですが、LINQを使用することでコーディングの意図が伝わりやすい場合などもあります。これらの挙動を把握した上で、LINQを使用するか、使用する場合のルールなどはプロジェクト内で議論の余地があるかと思います。

10.6 async/awaitのオーバーヘッドの避け方

async/awaitはC#5.0で追加された言語機能であり、非同期処理をコールバックを使わず一筋の同期的処理のように記述できるものです。

10.6.1 不要な箇所でのasyncを避ける

asyncを定義されたメソッドは、コンパイラによって非同期処理を実現するためのコードが生成されます。そしてasyncキーワードがあれば、コンパイラによるコード生成は必ず行われます。そのため、リスト10.27のように同期的に完了する可能性のあるメソッドも実際にはコンパイラによるコード生成が行われています。

リスト10.27: 同期的に完了する可能性のある非同期処理

 1: using System;
 2: using System.Threading.Tasks;
 3: 
 4: namespace A {
 5:     public class B {
 6:         public async Task HogeAsync(int i) {
 7:             if (i == 0) {
 8:                 Console.WriteLine("i is 0");
 9:                 return;
10:             }
11:             await Task.Delay(TimeSpan.FromSeconds(1));
12:         }
13: 
14:         public void Main() {
15:             int i = int.Parse(Console.ReadLine());
16:             Task.Run(() => HogeAsync(i));
17:         }
18:     }
19: }

このリスト10.27のような場合は、同期的に終了する可能性のあるHogeAsyncを分割し、リスト10.28のように実装することで同期的に完了する場合に不要なIAsyncStateMachine実装のステートマシン構造体を生成するコストを省略できます。

リスト10.28: 同期処理と非同期処理を分割した実装

 1: using System;
 2: using System.Threading.Tasks;
 3: 
 4: namespace A {
 5:     public class B {
 6:         public async Task HogeAsync(int i) {
 7:             await Task.Delay(TimeSpan.FromSeconds(1));
 8:         }
 9: 
10:         public void Main() {
11:             int i = int.Parse(Console.ReadLine());
12:             if (i == 0) {
13:                 Console.WriteLine("i is 0");
14:             } else {
15:                 Task.Run(() => HogeAsync(i));
16:             }
17:         }
18:     }
19: }

async/awaitの仕組み

async/await構文はコンパイル時にコンパイラによるコード生成を用いて実現されています。asyncキーワードのついたメソッドはコンパイル時点でIAsyncStateMachineを実装した構造体を生成する処理が追加され、await対象の処理が完了するとステートを進めるステートマシンを管理することでasync/awaitの機能を実現しています。また、このIAsyncStateMachineSystem.Runtime.CompilerServices名前空間に定義されたインタフェースであり、コンパイラのみが使用可能なものとなっています。

10.6.2 同期コンテキストのキャプチャを避ける

別スレッドに退避させた非同期処理から、呼び出し元のスレッドに復帰する仕組みが同期コンテキストであり、awaitを使用することで直前のコンテキストをキャプチャーできます。この同期コンテキストのキャプチャはawaitのたびに行われるため、awaitごとのオーバーヘッドが発生します。そのため、Unityでの開発に広く利用されているUniTask*5では、同期コンテキストのキャプチャによるオーバーヘッドを避けるためにExecutionContextSynchronizationContextを使用しない実装となっています。Unityに関してはこのようなライブラリを導入することで、パフォーマンスの改善が見られる場合があります。

10.7 stackallocによる最適化

配列の確保は通常ヒープ領域に確保されるため、ローカル変数として配列を確保すると、都度GC.Allocが発生してスパイクの原因となります。また、ヒープ領域への読み書きはスタック領域と比べると、少しですが効率が悪くなります。

そのためC#では、unsafeコード限定で、スタック上に配列を確保するための構文が用意されています。

リスト10.29のように、newキーワードを用いる代わりに、stackallocキーワードを用いて配列を確保すると、スタック上に配列が確保されます。

リスト10.29: stackallocを用いたスタック上への配列確保

 1: // stackallocはunsafe限定
 2: unsafe
 3: {
 4:     // スタック上にintの配列を確保
 5:     byte* buffer = stackalloc byte[BufferSize];
 6: }

C# 7.2からSpan<T>構造体を用いることで、リスト10.30に示すようにunsafeなしでstackallocを利用できるようになりました。

リスト10.30: Span<T>構造体を併用したスタック上への配列確保

 1: Span<byte> buffer = stackalloc byte[BufferSize];

Unityの場合は2021.2から標準で利用できます。それ以前バージョンの場合はSpan<T>が存在しないため、System.Memory.dllを導入する必要があります。

stackallocで確保した配列はスタック専用なため、クラスや構造体のフィールドに持てません。必ずローカル変数として使う必要があります。

スタック上への確保とはいえ、要素数が大きい配列の確保はそれなりに処理時間がかかります。もしUpdateループ内などのヒープアロケーションを避けたい箇所で要素数の大きい配列を利用したい場合は、初期化時の事前確保か、オブジェクトプールのようなデータ構造を用意して、利用時に貸し出すような実装のほうがよいでしょう。

また、stackallocで確保したスタック領域は、関数を抜けるまで解放されない点に注意が必要です。たとえばリスト10.31に示すコードは、ループ内で確保した配列はすべて保持され、Hogeメソッドを抜けるときに解放されるので、ループを回しているうちにStack Overflowを起こす可能性があります。

リスト10.31: stackallocを用いたスタック上への配列確保

 1: unsafe void Hoge()
 2: {
 3:     for (int i = 0; i < 10000; i++)
 4:     {
 5:         // ループ数分配列が蓄積される
 6:         byte* buffer = stackalloc byte[10000];
 7:     }
 8: }

10.8 sealedによるIL2CPPバックエンド下でのメソッド呼び出しの最適化

UnityでIL2CPPをバックエンドとしてビルドをすると、クラスのvirtualなメソッド呼び出しを実現するために、C++のvtableのような仕組みを用いてメソッド呼び出しを行います*6

具体的には、クラスのメソッド呼び出しの定義ごとに、リスト10.32に示すようなコードが自動生成されます。

リスト10.32: IL2CPPが生成するメソッド呼び出しに関するC++コード

 1: struct VirtActionInvoker0
 2: {
 3:     typedef void (*Action)(void*, const RuntimeMethod*);
 4: 
 5:     static inline void Invoke (
 6:         Il2CppMethodSlot slot, RuntimeObject* obj)
 7:     {
 8:         const VirtualInvokeData& invokeData =
 9:             il2cpp_codegen_get_virtual_invoke_data(slot, obj);
10:         ((Action)invokeData.methodPtr)(obj, invokeData.method);
11:     }
12: };

これはvirutalなメソッドだけでなく、コンパイル時に継承をしていない、virtualでないメソッドであっても同様なC++コードを生成します。このような自動生成の挙動によって、コードサイズが肥大化したり、メソッド呼び出しの処理時間が増大します

この問題は、クラスの定義にsealed修飾子をつけることで回避できます*7

リスト10.33のようなクラスを定義してメソッドを呼び出した場合、IL2CPPで生成されたC++コードではリスト10.34のようなメソッド呼び出しが行われます。

リスト10.33: sealedを用いないクラス定義とメソッド呼び出し

 1: public abstract class Animal
 2: {
 3:     public abstract string Speak();
 4: }
 5: 
 6: public class Cow : Animal
 7: {
 8:     public override string Speak() {
 9:         return "Moo";
10:     }
11: }
12: 
13: var cow = new Cow();
14: // Speakメソッドを呼び出す
15: Debug.LogFormat("The cow says '{0}'", cow.Speak());

リスト10.34: リスト10.33のメソッド呼び出しに対応するC++コード

 1: // var cow = new Cow();
 2: Cow_t1312235562 * L_14 =
 3:     (Cow_t1312235562 *)il2cpp_codegen_object_new(
 4:         Cow_t1312235562_il2cpp_TypeInfo_var);
 5: Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
 6: V_4 = L_14;
 7: Cow_t1312235562 * L_16 = V_4;
 8: 
 9: // cow.Speak()
10: String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(
11:     4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);

リスト10.34に示すように、virtualなメソッド呼び出しではないにもかかわらずVirtFuncInvoker0< String_t* >::Invokeを呼び出しており、virtualメソッドのようなメソッド呼び出しが行われていることが確認できます。

一方で、リスト10.33Cowクラスをリスト10.35に示すようにsealed修飾子を用いて定義すると、リスト10.36のようなC++コードが生成されます。

リスト10.35: sealedを用いたクラス定義とメソッド呼び出し

 1: public sealed class Cow : Animal
 2: {
 3:     public override string Speak() {
 4:         return "Moo";
 5:     }
 6: }
 7: 
 8: var cow = new Cow();
 9: // Speakメソッドを呼び出す
10: Debug.LogFormat("The cow says '{0}'", cow.Speak());

リスト10.36: リスト10.35のメソッド呼び出しに対応するC++コード

 1: // var cow = new Cow();
 2: Cow_t1312235562 * L_14 =
 3:     (Cow_t1312235562 *)il2cpp_codegen_object_new(
 4:         Cow_t1312235562_il2cpp_TypeInfo_var);
 5: Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
 6: V_4 = L_14;
 7: Cow_t1312235562 * L_16 = V_4;
 8: 
 9: // cow.Speak()
10: String_t* L_17 = Cow_Speak_m1607867742(L_16, /*hidden argument*/NULL);

このように、メソッド呼び出しがCow_Speak_m1607867742を呼び出しており、直接メソッドを呼び出していることが確認できます。

ただし、比較的最近のUnityでは、このような最適化では一部自動で行われていることをUnity公式で明言しています*8

つまり、sealedを明示的に指定しない場合でも、このような最適化が自動で行われている可能性があります。

しかし、「[il2cpp] Is `sealed` Not Worked As Said Anymore In Unity 2018.3?」\footnotemark[8]というフォーラムで言及している通り、2019年4月の段階で、この実装は完全というわけではありません。

このような現状から、IL2CPPの生成するコードを確認しながら、プロジェクトごとにsealed修飾子の設定を決めるとよいでしょう。

より確実に直接的なメソッド呼び出しを行うために、また、今後のIL2CPPの最適化を期待して、最適化可能なマークとしてsealed修飾子を設定するのもよいかもしれません。

10.9 インライン化による最適化

メソッド呼び出しには多少のコストがかかります。そのため、C#に限らず一般的な最適化として、比較的小さいメソッドの呼び出しは、コンパイラなどによってインライン化という最適化が行われます。

具体的には、リスト10.37のようなコードに対して、インライン化によってリスト10.38のようなコードが生成されます。

リスト10.37: インライン化前のコード

 1: int F(int a, int b, int c)
 2: {
 3:     var d = Add(a, b);
 4:     var e = Add(b, c);
 5:     var f = Add(d, e);
 6: 
 7:     return f;
 8: }
 9: 
10: int Add(int a, int b) => a + b;

リスト10.38: リスト10.37に対してインライン化を行ったコード

 1: int F(int a, int b, int c)
 2: {
 3:     var d = a + b;
 4:     var e = b + c;
 5:     var f = d + e;
 6: 
 7:     return f;
 8: }

インライン化はリスト10.38のように、リスト10.37Funcメソッド内でのAddメソッドの呼び出しを、メソッド内の内容をコピーして展開することで行われます。

IL2CPPでは、コード生成時にはとくにインライン化による最適化は行われません。

しかし、Unity 2020.2からメソッドにMethodImpl属性を指定し、そのパラメーターにMethodOptions.AggressiveInliningを指定することで、生成されるC++コードの対応する関数にinline指定子が付与されるようになりました。つまり、C++のコードレベルでのインライン化が行えるようになりました。

インライン化のメリットは、メソッド呼び出しのコストが削減されるだけでなく、メソッド呼び出し時に指定した引数のコピーも省けることです。

たとえば算術系のメソッドは、Vector3Matrixのような、比較的サイズの大きい構造体を複数個引数に取ります。構造体はそのまま引数として渡すと、すべて値渡しとしてコピーされてメソッドに渡されるため、引数の個数や渡す構造体のサイズが大きいと、メソッド呼び出しと引数のコピーでかなりの処理コストがかかる可能性があります。また、物理演算やアニメーションの実装など、定期処理などで利用されることが多いため、メソッド呼び出しが処理負荷として見逃せないケースになることがあります。

このようなケースでは、インライン化による最適化は有効です。実際に、Unityの新しい算術系ライブラリであるUnity.Mathmaticsでは、いたるメソッド呼び出しにMethodOptions.AggressiveInliningが指定されています*9

一方で、インライン化はメソッド内の処理を展開する処理のため、展開した分コードサイズが増大するというデメリットがあります。

そのため、とくに1フレームで頻繁に呼び出されホットパスとなるようなメソッドに対して、インライン化を検討するとよいでしょう。また、属性を指定すると必ずインライン化が行われるわけではない点にも注意が必要です。

インライン化されるメソッドは、その中身が小さいものに限定されるため、インライン化を行いたいメソッドは処理を小さく保つ必要があります。

また、Unity 2020.2以前では属性指定に対してinline指定子がつかないのと、C++のinline指定子を指定してもインライン化が確実に行われる保証はありません。

そのため確実にインライン化を行いたい場合、可読性は落ちますがホットパスとなるメソッドは手動でのインライン化も検討するとよいでしょう。