コンテンツへスキップ

QualiArtsengineer blog

UnityにおけるC#のGC Allocation(ヒープメモリ確保)パターンの紹介

UnityにおけるC#のGC Allocation(ヒープメモリ確保)パターンの紹介

8 min read

サイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する子会社QualiArtsでUnityエンジニアをしている篠木です。本記事はQualiArtsの定期ブログ「QualiArts Tech Note」第12弾の記事となります。QualiArtsでは会社で使われている様々な技術の知見をブログで紹介しています。興味のある方は他の記事もチェックしてみてください。

この記事について

この記事ではGC Allocation(以下、メモリ確保)が行われるパターンとその詳細について書いています。軽いメモリ周りの知識がある前提で書いているので、もし知らない方や不安がある方がいましたら、そちらから調べてみるのをおすすめします。個人的にはこちらの記事が分かり易くまとまっているのでおすすめです。メモリ周りの知識に不安がある方は是非読んでみて下さい。

※本記事はUnity 2020.3.4f1の環境で動作確認をしています

※本記事の「Reference Source」と「SharpLab」へのリンクは実装が変更されている可能性があります

リストに関するメモリ確保

リスト関連の処理を知っておくと、無駄なメモリ確保を減らすことができる場合があります。今回は「List<T>」に関するメモリ確保について紹介します。

次のコードは List<int> へAddを5回行った際、コンストラクタでキャパシティを設定しないパターンとしたパターンでのメモリ確保の差をテストするものになっています。

Profiler.BeginSample("GCTest: new List<int>()");

// キャパシティを設定しない
var list = new List<int>();
for (var i = 0; i < 5; ++i)
{
    list.Add(i);
}
Profiler.EndSample();

Profiler.BeginSample("GCTest: new List<int>(5)");

// キャパシティを5に設定する
list = new List<int>(5);
for (var i = 0; i < 5; ++i)
{
    list.Add(i);
}
Profiler.EndSample();

実行結果

キャパシティを設定した方が60Bメモリ確保量が減っています。

これはList<T>が内部で配列を使用しているためです。キャパシティはこの配列の長さになります。Add等を行われた際に配列の長さが足りなくなると配列の長さを倍にして再確保(以下、リサイズ)が行われます。キャパシティの初期値は4(環境により変動する可能性あり)になっているため、キャパシティ設定なしのパターンだと5回目のAddでリサイズが発生します。つまり、長さ4の配列と長さ8の配列を生成しているため、メモリ確保量が増えています。当然、メモリ確保回数も配列一回分増えているため、キャパシティを設定した方が色々お得です。

Addが5回だと差は軽微ですが、例えば10000回だとすると13個(4から倍にしていき、10000を超えるのが13回目のため)の配列が生成されてしまうため、メモリ確保量も回数も大きな差が生まれます。

List<T>以外にもキャパシティを設定できるクラスの実装は同じような処理が多いので、できる限りキャパシティを設定するのがおすすめです。

補足:引数なしコンストラクタの場合、配列の生成タイミングは配列が使用される際なので、コンストラクタでキャパシティを設定しなくても、Add等を行う前にキャパシティを設定すれば余計な配列を生成しなくて済みます。

抽象度を上げることによるメモリ確保

抽象度を上げることにより、メモリ確保が行われてしまうことがあります。例としてList<T>と配列をIListにキャストし、foreachを行うことにより発生するメモリ確保について紹介します。

Boxingによるメモリ確保

まずList<T>をIListにキャストし、foreachを行った際に発生するメモリ確保について紹介します。

次のコードはList<T>をそのままforeachした時と、IList<T>にキャストした際に発生するメモリ確保をテストするためのものです。

var list = new List<object>(1);
IList<object> iList = list;

Profiler.BeginSample("List Foreach");
foreach (var val in list)
{
}
Profiler.EndSample();

Profiler.BeginSample("IList Foreach");
foreach (var val in iList)
{
}
Profiler.EndSample();

実行結果

List<T>にはIEnumeratorを継承した構造体が用意されており、GetEnumerator(foreachはコンパイラによりGetEnumeratorを使用した処理に展開されます)が呼ばれた際にはこれを返すようになっています。これは構造体のため、メモリ確保が行われません。しかし、IListにキャストするとGetEnumeratorが呼ばれた際に構造体そのものではなく、IEnumerator型として受け取るようになります。その際Boxingが発生し、メモリ確保が行われてしまいます。

コンパイラの最適化が行われないことによるメモリ確保

続いて配列をIListにキャストし、foreachを行った際に発生するメモリ確保について紹介します。

次のコードは配列をそのままforeachした時と、IList<T>にキャストした際に発生するメモリ確保をテストするためのものです。

var array = new object[1];
IList<object> iList = array;

Profiler.BeginSample("Array Foreach");
foreach (var val in array)
{
}
Profiler.EndSample();

Profiler.BeginSample("IList Foreach (Array)");
foreach (var val in iList)
{
}
Profiler.EndSample();

実行結果

配列にはIEnumeratorを継承したクラスが用意されており、GetEnumeratorが呼ばれた際にはこのクラスが生成されるため、メモリ確保が行われます。ではなぜ配列をそのままforeachした際にはメモリ確保が行われないかというと、配列でのforeachは特別にコンパイラによる展開がfor文を使用するコードになるため、GetEnumeratorを使用していないためです。IListにキャストすることで、この最適化が行われなくなり、メモリ確保が行われるようになります。

この項のまとめ

List<T>と配列でIListにキャストし、foreachを行った際のメモリ確保はそれぞれ次の理由です。

  • List<T>: 構造体がインターフェースにキャストされることによるBoxing
  • 配列 : コンパイラによる最適化が外れるため

特に構造体をインターフェースにキャストして受け取ることによるBoxingは気づかないうちに行ってしまうことが多いと思うので、注意が必要です。

コンパイラによる最適化が外れることによるメモリ確保はあまり起きないとは思いますが、思考の片隅に置いておくと役立つ時が来るかも知れません。

コンパイラの展開によるメモリ確保

C#はコンパイラによってクラスが生成されることが多いです。この項ではその一部を紹介します。

※この項ではSharpLabというサイトへのリンクが貼られています。こちらのサイトではC#コードの展開後のコード(デコンパイルされたコード)が確認できるようになっています。それぞれ左側がC#コード、右側が展開されたコードになります。

ラムダ式(匿名関数)によるメモリ確保

ラムダ式を使用するとクラスが生成されます。この生成されるクラスはローカル変数を使用する場合と使用しない場合で処理が異なっています。

ローカル変数を使用しないラムダ式

ローカル変数を使用しないラムダ式は、最初に使用された際にクラスのインスタンスが生成され、そのインスタンスが使い回されるようになっています。そのため、複数回使用してもメモリ確保は一回のみです。

ラムダ式の展開処理

ローカル変数を使用するラムダ式(クロージャ)

クロージャは使用される度にクラスのインスタンスが生成されます。そのため、使用する毎にメモリ確保が行われます。

クロージャの展開処理

yield return と await に関するメモリ確保

yield returnawaitが行われるメソッドが使用される度にメモリ確保が行われます。これはコンパイラによりクラスが生成される処理に置き換わるためです。このクラスは状態を持っており、MoveNextが呼ばれる度に次のyield return(またはawait)までの処理が行われるようになっています。for文のようなループ処理がある場合も同様で、ループ処理の中身を頑張って展開してくれます。本題とはズレてしまいますが、一度どのような展開が行われているのか見てみると面白いので、個人的におすすめです。

yield return展開処理

await展開処理

Unity特有のメモリ確保

Unity特有のプロパティにアクセスした際にメモリ確保が行われることがあります。これは結構厄介で、一見無害そうなプロパティでも実際は毎回メモリ確保が行われていたりします。例として「UnityEngine.Object.name」へアクセスした際のメモリ確保について紹介します。

次のコードはUnityEngine.Object.nameへアクセスした際のメモリ確保を確認するためのコードになります。

Profiler.BeginSample("GCTest: Object Name");
// MonoBehaviourを継承したクラス内
var n = this.name;
Profiler.EndSample()

実行結果

オブジェクトの名前を取得するプロパティでもメモリ確保が行われています。この名前の取得は次のような処理になっています。

UnityCsRefarence から引用

[FreeFunction("UnityEngineObjectBindings::GetName")]
extern static string GetName(Object obj);

処理を見ると名前の取得がネイティブコードから行われています。C#メモリ空間とネイティブコードのメモリ空間は共有できないため、C#側へ値を持ってくる際、メモリ確保が行われているのだと思います。

このように一見無害そうなプロパティでも、実際にはメモリ確保が行われているパターンが多く、Unity特有のプロパティへ大量にアクセスする際やパフォーマンスに気をつけたい箇所ではどの程度メモリ確保が行われているかを確認しておくと良いかもしれません。

最後に

メモリ確保量や回数は環境によって大きく異なることがあります。そのため、自分の手元で様々な処理の確認が行えるようにしておくのが大切だと思います。最後に自分が実装の確認等に良く使用しているサイトへのリンクを貼っておきますので、よければ使ってみて下さい。本記事がC#のコードを書く際の手助けになれば幸いです。

Reference Source

.NET Frameworkの実装を確認できるサイトです。このAPIでメモリ確保が行われる理由を知りたい時や単純に実装を確認したい場合等に使用しています。

SharpLab

C#コンパイラの展開後の処理やILになった際の処理を確認するのに使用しています。Inspect.Heap()Inspect.Stack()等を使用して、メモリの確保量や状態等も確認できます。

UnityCsReference

UnityのC#コードを確認できます。メモリ確保が行われる理由を調べたり、リフレクション等により、Unityの内部APIを使用したい時に使用しています。

2017年にQualiArtsにUnityエンジニアとして中途入社。現在は開発推進室に所属し、複数のプロジェクトで使用する基盤開発に携わる。