Unityでゲームやアプリを開発する際、GC Allocの蓄積によるガベージコレクションの発生は極力避けたいところです。特に、ゲームのメインループ処理内でGC Allocがあると頻繁にガベージコレクションが発生してしまうため、気を付けなければなりません。
この記事では、自分が普段GC Allocを避けるために意識しているポイントを書いていきます。
GC Allocとは?
UnityのProfilerのCPU Usageでは、フレームごとのヒープメモリアロケーションを「GC Alloc」として確認することができます(公式ドキュメント)。
公式ドキュメントではGC Allocのことをこう説明しています。
Unity が現在のフレームに割り当てたスクリプティングのヒープメモリの量。スクリプティングのヒープメモリは、ガベージコレクタ によって管理されます。
ガベージコレクタはC#に実装されているヒープメモリ管理の仕組みで、ヒープメモリへの割り当て(アロケーション)が溜まってくるとガベージコレクションの処理が実行されます。ガベージコレクションは結構重い処理で、これが走ると1フレーム内に処理が収まらず、いわゆる「フレーム落ち」が発生してしまうことがあります。
Unityによる開発においても、シーン実行中のGC Allocをゼロに保つのが理想です。上記の公式ドキュメントにもこう書いてあります。
Unity は、アプリケーションがヒープにより多くを割り当てると、ガベージコレクターをより頻繁に実行します。 マネージヒープが増加すると、Unity がメモリにマークして回収するのに時間がかかります。そのため、アプリケーションの実行中は GC Alloc 値をゼロに保ち、ガベージコレクターがアプリケーションのフレームレートに影響を与えないようにし、全体的なヒープサイズを小さく保つ必要があります。
GC Allocを発生させてみる
試しに、わざとGC Allocが起きるスクリプトを書いてGC Allocを発生させてみましょう。
以下のスクリプトを書き、シーン上に空のGameObjectを作ってアタッチします。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GCAllocSample : MonoBehaviour
{
private const string longString = "fjlkajeiofkajsdk;]ksdajlk;fjlk;ajwe;lkfajsdo;i`djr;foi;jal;sfdja;soifje;wsjef;djoljadksjlfjclja";
void Update()
{
var log = "";
for (int i = 0; i < 1000; i++)
{
log += longString + ",";
}
}
}
※スクリプトの処理内容としては、1フレームでstringを1000回足し合わせているというものになります。後述しますが、このようにstringを足し合わせると、足し合わせるたびにヒープメモリアロケーションが起きるため、結果として大量のアロケーションになります。
そして、Unity Editor上で実行しProfilerを開くと、GC Allocの値は以下のようになっていました。
1フレームにつき、91.7 MBのアロケーションが発生していることが分かります。
ProfilerのTimelineを見ると、GC.Collectが何度も呼ばれており、それによってFPSも30を下回ってしまっていることが分かります。
足し合わせている元々のstringはそこまで大きなデータサイズではありませんが、このように良くない実装をすると、1000回の足し合わせの処理だけでフレームレートが大幅に低下してしまうことが分かりました。
ヒープメモリアロケーションを回避する
次に、自分が業務上特に気を付けているヒープメモリアロケーションのパターンとその回避方法をいくつか紹介していきます。
Stringの連結
Stringの足し算(+オペランドによる連結)をすると、余計なアロケーションにつながります。先ほどのGC Allocを発生させてみるで使用したパターンです。
Stringの+演算子を使うと、その連結の処理のたびに、連結した結果のStringがヒープメモリに書き込まれます。すなわち、
string a = "1"+"2"+"3"+"4"+"5";
というコードを書くと、
"1", "2", "3", "4", "5", "12", "123", "1234", "12345"
というstringがすべてヒープメモリ上に生成されてしまうということになります。これは、処理の効率としては非常に悪いです。
StringBuilderやString.Joinを使用することで中間の文字列データのアロケーションを回避できます。
先ほどのコードでは、91.7 MBのアロケーションが発生していました。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GCAllocStringBadSample : MonoBehaviour
{
private const string longString = "fjlkajeiofkajsdk;]ksdajlk;fjlk;ajwe;lkfajsdo;i`djr;foi;jal;sfdja;soifje;wsjef;djoljadksjlfjclja";
void Update()
{
var log = "";
for (int i = 0; i < 1000; i++)
{
log += longString + ",";
}
}
}
一方、StringBuilderを使用したコードは以下のようになります。
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
public class GCAllocStringGoodSample : MonoBehaviour
{
private const string longString = "fjlkajeiofkajsdk;]ksdajlk;fjlk;ajwe;lkfajsdo;i`djr;foi;jal;sfdja;soifje;wsjef;djoljadksjlfjclja";
private const string comma = ",";
StringBuilder stringBuilder = new (); //StringBuilderが確保したメモリ領域を使いまわしたいので、メンバ変数として定義しておく
void Update()
{
stringBuilder.Clear();
for (int i = 0; i < 1000; i++)
{
stringBuilder.Append(longString);
stringBuilder.Append(comma);
}
string log = stringBuilder.ToString();
}
}
そして、このコードを実行した際のProfilerは以下の通りです。
GC Allocは187.5 KBに減り、FPSも60を安定して上回るようになりました。
このように、stringの連結の方法を変えることで、GC Allocを減らすことができます。
一方、String Builderを使用しても187.5 KBのGC Allocは起きてしまっています。toString()で文字列をインスタンス化している以上、アロケーションをゼロにすることはできません。実際のゲーム制作では、毎フレーム文字列が生成されるような処理は極力避けましょう。やむをえず処理する場合は、StringBuilderやString.Joinを使用してアロケーションを軽減しましょう。
ListとArrayの生成
ListとArrayもnewで生成するとその大きさぶんのヒープメモリアロケーションが発生します。
まず悪い例です。
using System.Collections.Generic;
using UnityEngine;
public class GCAllocListBadSample : MonoBehaviour
{
void Update()
{
// 毎フレーム新しいリストを生成
List<int> numbers = new List<int>();
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
}
こちらの実行結果が以下になります。128.4 KBのGC Allocが生じています。
一方、改善した例がこちらになります。
using System.Collections.Generic;
using UnityEngine;
public class GCAllocListGoodSample : MonoBehaviour
{
private List<int> numbers = new List<int>(); // リストをクラスメンバとして保持
void Update()
{
// 前フレームのデータをクリアしてリストを再利用
numbers.Clear();
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
}
こちらの結果が以下です。
GC Allocを0にすることができました!
変えた点は、毎フレームListをnewしていたのを、クラスの初期化時のみにnewするようにし、その後はList.Clear()で中身をリセットするようにしたことです。
List.Clear()は、ヒープメモリ上に確保された領域はそのままにリストの中身だけをリセットするという処理になっているため、GC Allocは0に保たれます。
ちなみに、今回は配列の長さが10,000で固定なのでList.Add()をしても最初のフレーム以外はGC Allocが起きませんが、配列の長さが変動する場合は、List.Add()によってGC Allocが発生することもあるので注意が必要です。Arrayを使えば、配列の長さが変わらないことが保証されるため、Listでなくてもよい時はArrayを使った方が無難です。
ボックス化
C#にはボックス化という仕組みがあります。これは、値型の変数を参照型として扱うときに必要な仕組みとなっています。
ボックス化の仕組みは少し複雑で、説明するとそれだけで一つの記事になってしまうため、詳しくは以下のリンクをご覧ください。
公式ドキュメント→https://learn.microsoft.com/ja-jp/dotnet/csharp/programming-guide/types/boxing-and-unboxing
有志の方のサイト→https://ufcpp.net/study/csharp/RmBoxing.html
ここでは、ボックス化が起きるとGC Allocが発生するということだけ覚えていただければ大丈夫です。
まず悪い例(ボックス化が発生する例)です。
using UnityEngine;
public class GCAllocBoxBadSample : MonoBehaviour
{
const int number = 1000;
void Update()
{
int sum = 0;
for(int i=0; i<1000; i++)
{
sum += (int)GetObject(number);
}
}
private object GetObject(object value)
{
return value;
}
}
実行結果が以下です。19.5 KBのGC Allocが発生しています。
次にボックス化が発生しないようにした例です。
using UnityEngine;
public class GCAllocBoxGoodSample : MonoBehaviour
{
const int number = 1000;
void Update()
{
int sum = 0;
for(int i=0; i<1000; i++)
{
sum += GetInt(number);
}
}
private int GetInt(int value)
{
return value;
}
}
悪い方の例では、GetObject()関数によって、int(値型)が一度object(参照型)に変換され、再度intにキャストされていました。このobjectへ変換するときにボックス化という処理が走り、GC Allocが発生してしまいます。
実際の開発で関数の引数をobjectにしたい場合は、結構あるのではないかと思います。例えば、引数の型が確定していない状況で何らかの処理を行いたい場合などです。そのような場合でも、以下のような回避策を取ることができます。
Genericを使う
関数をGenericメソッドとして定義することで、ボックス化を回避できる場合があります。
byte配列を使う
特に通信部分やデータ入力部分で使える方法ですが、扱いたいデータをbyte配列に格納することで、予め用意した配列の領域以上のアロケーションは回避できます。
IEnumerableに対するforeach
IEnumerableに対してforeachを実行すると、GC Allocが発生します。
まずはIEnumerableを使った場合のサンプルコードです。
using System.Collections.Generic;
using UnityEngine;
public class GCAllocIEnumerableBadSample : MonoBehaviour
{
private int[] numbers = new int[10000]; // 配列をクラスメンバとして保持
void Update()
{
Run(numbers);
}
private void Run(IEnumerable<int> list)
{
int sum = 0;
foreach (var item in list)
{
sum += item;
}
}
}
このコードでは、Run()メソッドでint[]をIEnumerableとして受け取り、foreachを実行しています。
このコードの実行結果が以下になります。
32 Bと少ないですが、GC Allocが発生しています。
次に、IEnumerableを使わず、int[]のままforeachを実行した場合を見てみましょう。
コードはこちらです。
using System.Collections.Generic;
using UnityEngine;
public class GCAllocIEnumerableGoodSample : MonoBehaviour
{
private int[] numbers = new int[10000]; // 配列をクラスメンバとして保持
void Update()
{
Run(numbers);
}
private void Run(int[] list)
{
int sum = 0;
foreach (var item in list)
{
sum += item;
}
}
}
こちらの実行結果が以下です。
GC Allocは0になっています。
なぜ、IEnumerableとしてforeachを実行すると、GC Allocが発生してしまったのでしょうか?その原因は、IEnumerableとして扱う場合はコンパイラの最適化が効かなくなることにあるようです。ListやArrayをforeachにかけた場合、最近のUnityのコンパイラでは、GC Allocの発生しないfor文と同等のコードに変換してくれるようです。しかし、IEnumerableやそれを継承したIReadOnlyCollection、IListの場合などはその最適化が効かず、foreach文の箇所でGetEnumeratorが呼ばれたときにIEnumeratorへボックス化されてEnumeratorが返ることで、GC Allocが発生してしまうということのようです。
対策としては、頻繁に実行される処理にはListやArrayをそのまま使用し、IEnumerableなどのインターフェースへのキャストは行わない、ということになります。コレクション系のインターフェースは便利なので使いたくなりますが、UnityとC#の仕様上、インターフェースを使った場合のGC Allocを回避することは難しいため、極力使わないようにするのが現実的な対策となりそうです。
インターフェースを使うことで処理が抽象化できたり、クラスの外部での意図しない変更(ListへのAddなど)を防ぐことができるため、そういった利便性とパフォーマンスのトレードオフということになります。
こちらの記事を参考にさせていただきました↓。より詳しく知りたい方はご覧ください。
https://blog.virtualcast.jp/blog/2020/03/foreach_gc_alloc/
まとめ
以上になります。この記事で上げた以外にもGC Allocが発生するパターンはありますが、今回は実際にコーディングする上で自分がよく引っかかるポイントを並べてみました。個別のパターンについてはネット上に記事がありますので、より詳しく知りたい方は検索して調べてください。
ありがとうございました。