マネージド ヒープの予期せぬ増大も、 Unity デベロッパーがしばしば直面する問題のひとつです。 Unity では、マネージド ヒープが縮小するよりも遥かに増大しやすい傾向にあります。さらに、 Unity のガベージコレクションの方式はメモリを断片化しやすく、そのために増大したヒープの縮小が阻まれることがあります。
「マネージド ヒープ」とは、メモリ内で、プロジェクトのスクリプト ランタイムのメモリマネージャー (Mono か IL2CPP)によって自動的に管理(マネージ)されるセクションのことです。マネージド コード内で作成されたオブジェクトは全てマネージド ヒープに割り当てられる必要があります(2)(__[注]__ 厳密には、 null でない参照型オブジェクトの全てとボックス化された値型オブジェクトの全てが、マネージド ヒープに割り当てられる必要があります)。
上の図をご覧ください。白いボックスはマネージド ヒープに割り当てられたメモリ量を示しており、中にある色付きボックスが、マネージド ヒープのメモリ領域内に格納されたデータの値を示しています。追加の値が必要になると、マネージド ヒープ内から追加領域が割り当てられます。
ガベージコレクターは周期的に実行されます (3) ( 注 正確なタイミングはプラットフォームによって異なります)。これにより、ヒープ上の全てのオブジェクトが一斉調査され、既に参照されなくなっている全てのオブジェクトが選別されます。その後、参照の外されたオブジェクトが削除され、メモリ領域が解放されます。
ここで重要なことは、Unity のガベージコレクションが Boehm GC アルゴリズム を使用しており、非世代別で非圧縮型であることです。「非世代別」とは、コレクションを 1 回実行するごとに GC がヒープ全体を一斉調査しなければならないことを意味しており、このため、ヒープが拡張するに応じてパフォーマンスが劣化します。「非圧縮型」とは、オブジェクト同士の隙間を埋めるためにメモリ内のオブジェクトが移動されないことを意味します。
上の図はメモリの断片化の一例を示しています。特定のオブジェクトが解放されると、そのメモリが解放されます。しかし、解放された領域は、まとまったひとつの大きな「空の領域」プールの一部にはなりません。解放されたオブジェクトの隣にあるオブジェクトはまだ使用されている可能性があります。このため、解放された領域は、メモリ内の別のセグメント同士との間の「隙間」になります(上図内では赤丸の部分がこの隙間を表しています)。したがって、新しく解放された領域は、解放されたオブジェクトと同じか、それ以下のサイズのデータの格納にしか使用できません。
オブジェクトを割り当てる際は、オブジェクトは常に、メモリ内のまとまったひと続きの領域を占有しなければならないことにご注意ください。
このことが、メモリ断片化に繋がっています。ヒープ内の使用可能領域の合計量が大きくても、たくさんの小さな領域で区切られている可能性があります。この場合、合計サイズで見ると特定の割り当てを行うのに十分な空き領域があったとしても、マネージドヒープは、実際にそれを行うために十分な領域を見付けられないということになります。
しかし、上図のように、大きなオブジェクトが割り当てられ、そのオブジェクトに対応できる十分なサイズの空き領域がある場合は、 Unity のメモリマネージャーは 2 つの処理を実行します。
まず、(まだ行われていない場合は)ガベージコレクションが実行されます。これは、割り当てリクエストに応えるために十分な領域を解放するために行われます。
もしガベージコレクション実行後にまだリクエストされた量のまとまったメモリ領域がない場合、ヒープは拡張しなければなりません。具体的な拡張量はプラットフォームによって異なりますが、ほとんどの Unity プラットフォームでは、マネージヒープのサイズが 2 倍になります。
マネージドヒープの拡張に関する主な問題は、2 つの要素から成るものです。
Unity では、マネージヒープが拡張する際、そのマネージヒープに割り当てられたメモリページが解放されることは稀です。拡張したヒープの大部分が空であったとしても Unity は拡張したヒープを維持し続けます。これは、後に大きな割り当てが更に行われた場合にヒープを再拡張しなくて済むようにするためです。
ほとんどのプラットフォームで、Unity はある時点で、マネージヒープの空部分に使用されるページを解放し、OS に返します。これが行われる間隔は一定であるとは限らないので注意してください。
マネージドヒープによって使用されたアドレス空間は使用中に OS へ返還されることはありません。
32 ビットのプログラムの場合、マネージドヒープが拡張と縮小を何度も繰り返すと、アドレス空間の消耗に繋がる場合があります。プログラムが使用できるメモリアドレス空間が不足すると、 OS によってプログラムが終了されます。
64 ビットのプログラムの場合はアドレス空間が十分大きいので、プログラムの実行時間が人間の寿命を超えるものでなければ、これが起こる可能性は極めて低くなります。
1 フレーム毎に何十、何百キロバイトという一時データがマネージドヒープに割り当てられる形で実行されている Unity プロジェクトが頻繁に見られます。これはプロジェクトのパフォーマンスを著しく劣化させます。以下の例を参考にしてください。
1 フレーム毎に 1KB の一時メモリが割り当てられ、60 FPS で実行されるプログラムの場合、1 秒毎に 60 KB の一時メモリを割り当てる必要があります。これは 1 分間で 3.6 MB のガベージをメモリ内に発生させる計算になります。ガベージコレクターを 1 秒に 1 回実行すればパフォーマンスに悪影響を与える可能性が高くなりますが、1 分間に 3.6 MB を割り当てるのは、低メモリのデバイスで実行する場合に問題となります。
さらに、読み込み処理について考えてみましょう。負荷の高いアセットの読み込み中に大量の一時オブジェクトが生成され、処理が完了するまでそれらのオブジェクトが参照され続けるとすれば、ガベージコレクターが一時オブジェクトを解放することができません。マネージドヒープは、中にあるオブジェクトの多くがすぐに解放されるにも関わらず、拡張されなければなりません。
マネージドメモリの割り当てを追跡するのは比較的簡単です。Unity の CPU Profiler の Overview に「GC Alloc」列があります。この列には、特定のフレーム (4) のマネージドヒープに割り当てられたバイト数が表示されます ( 注: これは、指定されたフレームで一時的に割り当てられるバイト数とは異なります。プロファイルは、その特定のフレームに割り当てられたバイト数を表示します。ただし、割り当てられたメモリの一部または全部が後続のフレームで再使用されますが)。Deep Profiling オプションを有効にすると、これらの割り当てが発生するメソッドを追跡することができます。
Unity の Profiler は、これらの割り当てがメインスレッド以外から発生したときには追跡しません。したがって「GC Alloc」列は、ユーザーが作成したスレッドで発生するマネージドアロケーションの測定には使用できません。デバッグの目的では、コードの実行を別々のスレッドからメインスレッドに切り替えます。
常に、マネージドアロケーションはターゲットデバイスの開発ビルドで検証してください。
スクリプトメソッドによっては、エディターで実行される場合に割り当てを発生させ、プロジェクトのビルド後には割り当てを発生させないものもあります。最も一般的な例は GetComponent
です。このメソッドはエディターで実行される際には常に割り当てを行いますが、ビルドされたプロジェクトでは行いません。
基本的には、プロジェクトがインタラクティブな状態にある部分では常に、マネージヒープの割り当てを極力削減することが強く推奨されます。シーン読み込みなどの非インタラクティブ処理においては、これはそれほど問題にはなりません。
Visual Studio の Jetbrains Resharper Plugin は、コードの割り当ての配置をすることができます。
Unity の Deep Profile モードを使用して、マネージドアロケーションの特定の原因を見つけます。Deep Profile モードでは、すべてのメソッド呼び出しが個別に記録され、メソッド呼び出しツリー内のどこでマネージドアロケーションが発生するかがより明確になります。Deep Profile モードはエディターでのみ正しく機能します。デバイス上では使用しないでください。
マネージヒープの割り当てを削減する比較的簡単な方法がいくつかあります。
C# の Collection クラスや配列を使用する場合は、可能な限り、割り当てられた Collection や配列の再利用やプールを検討してください。 Collection クラスは、 Collection の値を削除する Clear メソッドにアクセスできますが、割り当てられたメモリの開放は行いません。
void Update() {
List<float> nearestNeighbors = new List<float>();
findDistancesToNearestNeighbors(nearestNeighbors);
nearestNeighbors.Sort();
// … ソートしたリストを使用します …
}
これは、複雑な計算のために一時的な「補助的」 Collection を割り当てる場合に特に役立ちます。以下にごく簡単な例を示します。
この例では、一式のデータポイントを収集するために nearestNeighbors
List が 1 フレームに 1 回割り当てられています。この List をメソッドから引き出して、それを含むクラス内に挿入するのはとても簡単です。そうすることで、 1 フレーム毎に新しい List を割り当てるのを防ぐことができます。
List<float> m_NearestNeighbors = new List<float>();
void Update() {
m_NearestNeighbors.Clear();
findDistancesToNearestNeighbors(NearestNeighbors);
m_NearestNeighbors.Sort();
// … 何らかの方法でソートされたリストを使用します …
}
このバージョンでは List のメモリが維持されて複数のフレームで再利用されています。新しいメモリは List が拡大される必要のある時にのみ割り当てられます。
クロージャおよび匿名メソッドを使用する場合には考慮すべき点が 2 つあります。
1 つ目は、 C# 内のメソッド参照は全て参照型であり、したがってヒープに割り当てられるということです。一時割り当ては、メソッド参照を引数として渡すことで簡単に作成できます。この割り当ては、渡されているメソッドが匿名メソッドか事前定義されたメソッドかに関わらず起こります。
2 つ目は、匿名メソッドをクロージャに変換すると、クロージャを (それを受領する) メソッドへ渡すために必要とされるメモリ量が大幅に増加するということです。
次のコードを考えてみてください。
List<float> listOfNumbers = createListOfRandomNumbers();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/2))
);
このスクリプトは、単純な匿名メソッドを使用して、最初のラインで作成される数のリストの並び順を制御しています。しかし、プログラマーがこのスクリプトを再使用可能にしたい場合、以下のように、定数 2
の代わりにローカルスコープ内で変数を使用するかもしてません。
List<float> listOfNumbers = createListOfRandomNumbers();
int desiredDivisor = getDesiredDivisor();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/desiredDivisor))
);
この時点で、この匿名メソッドは、そのスコープ外の変数のステートにアクセスできることが必要なため、クロージャとなりました。 desiredDivisor
変数がクロージャの実際のコードによって使用可能となるためには、何らかの形でこのクロージャに渡される必要があります。
これを行うために C# は、このクロージャによって必要とされる、外側のスコープの変数を保持する匿名クラスを生成します。このクラスのコピーが、 Sort
メソッドにクロージャが渡された時にインスタンス化されます。このコピーは desiredDivisor
の整数で初期化されます。
このクロージャの実行には、その生成されたクラスのインスタンス化が必要であり、全てのクラスは C# においては参照型であるため、このクロージャの実行にはマネージヒープ上のオブジェクトの割り当てが必要になります。
基本的には、 C# 内ではクロージャは可能な限り避けることが推奨されます。匿名メソッドとメソッド参照は、パフォーマンスに影響を与えやすいコード、特にフレーム毎に実行されるコードでは、最小限に抑えるようにしてください。
現段階で、 IL2CPP によって生成されたコードを調べて分かるのは、 System.Function
型の変数の基本的な宣言と指定によって、新しいオブジェクトが割り当てられるということです。これは、変数が明示的 (メソッドあるいはクラス内で宣言されている) か、暗示的 (他のメソッドへ引数として宣言されたもの) であるかに関わらず言えることです。
このように、 IL2CPP スクリプトバックエンド下の匿名メソッドの使用は、常にマネージメモリを割り当てます。Mono スクリプトバックエンド下ではこれは当てはまりません。
さらに IL2CPP は、メソッド引数の宣言方法によってマネージメモリの割り当ての度合いが大幅に変化します。クロージャは、期待通り 1 回の呼び出しにつき最も多くのメモリを割り当てます。
意外なことに、事前定義されたメソッドは、 IL2CPP スクリプトバックエンド下で引数として渡された場合、クロージャにほぼ匹敵する量のメモリ を割り当てます。ヒープに生成される一時的ガベージの量が最も少ないのは匿名メソッドで、他と比べて 1 桁または 2 桁以上の違いがあります。
したがって、プロジェクトが IL2CPP スクリプトバックエンドで配信 (販売) される予定である場合には、推奨される重要事項が 3 つあります。
メソッドを引数として渡す必要のないスクリプトを優先的に使用する。
それが不可避な場合は、なるべく名前付きメソッドではなく匿名メソッドを使用する。
スクリプトバックエンドの種類に関わらず、クロージャを避ける。
Unity プロジェクトで、一時メモリの意図せぬ割り当ての原因として最もよくあるもののひとつが、ボックス化です。これは、値型の値が参照型として使用されると起こります。最もよくあるのが、プリミティブな値型変数( int
や float
など)をオブジェクト型メソッドに渡す場合です。
以下は、ごく簡単な例です。 x 内の整数が、 object.Equals
メソッドに渡されるためにボックス化されています。これは、 object
の Equals
メソッドに、 object
が 1 つ渡される必要があるためです。
int x = 1;
object y = new object();
y.Equals(x);
C# IDE とコンパイラは意図せぬメモリ割り当てを発生させますが、ボックス化に関する警告は基本的に出しません。これはなぜかと言うと、 C# は「小さな一時割り当ては、世代別ガベージコレクターや、割り当てサイズに応じて調整されるメモリプールによって効率的に処理される」ことを前提に開発されたものだからです。
Unity のアロケーターは割り当ての大小に応じて異なるメモリプールを使用しますが、Unity のガベージコレクターは世代別ではない
ため、ボックス化によって頻繁に生成される小さな割り当てを効率的に一掃することができません。
Unity ランタイム用に C# を書く場合には、ボックス化は極力避けるようにしてください。
ボクシングは、いくつかのメソッドのうちの 1 つへの呼び出しとしてCPU トレースに表示されます。これらは通常、以下のうち 1 つの形式を取ります。<some class>
は別のクラスや構造体の名前で、 …
はいくつかの引数です。
<some class>::Box(…)
Box(…)
<some class>_Box(…)
これは逆コンパイラーや IL Viewer の出力を検索することでも特定できます。例えば ReSharper に搭載の IL Viewer ツールや、 dotPeek 逆コンパイラーなどです。 IL 命令は「box」です。
ボックス化のよくある原因のひとつは、 Dictionary のキーに enum
型を使用することです。 enum
を宣言すると新しい値型が作成され、これは裏では整数のように扱われますが、コンパイル時には型の検証を行ないます。
デフォルトでは Dictionary.add(key, value)
への呼び出しは Object.getHashCode(Object)
への呼び出し行ないます。このメソッドは、 Dictionary のキー用に適切なハッシュコードを取得するために使用され、また、 Dictionary.tryGetValue や Dictionary.remove
などのキーを受け取る全てのメソッドで使用されます。
Object.getHashCode
メソッドは参照型ですが、 enum
値は常に値型です。したがって、enum をキーにした Dictionary では、全てのメソッドの呼び出しによって、キーは少なくとも 1 回はボックス化されることになります。
次のスクリプトは、このボックス化の問題を示す簡単な例です。
enum MyEnum { a, b, c };
var myDictionary =
new Dictionary<MyEnum, object>();
myDictionary.Add(MyEnum.a, new object());
この問題を解決するには、 IEqualityComparer
インターフェースを使用したカスタムクラスを書いて、 Dictionary の Comparer としてそのクラスのインスタンスを 1 つ割り当てる必要があります( 注 このオブジェクトは通常ステートレスなので、異なる Dictionary インスタンスで再使用することでメモリを節約することができます)。
以下は、上のスクリプト用の IEqualityComparer の簡単な一例です。
public class MyEnumComparer : IEqualityComparer<MyEnum> {
public bool Equals(MyEnum x, MyEnum y) {
return x == y;
}
public int GetHashCode(MyEnum x) {
return (int)x;
}
}
上記のクラスのインスタンスは Dictionary のコンストラクターに渡すことも可能です。
Unity 版の Mono C# コンパイラでは、 foreach
ループを使用すると、ループが終了する度に強制的に値が 1 つボックス化されます。(__[注]__ 値は、ループ全体の実行が 1 回完了する度にボックス化されます。ループの要素が 1 回実行するごとにボックス化される訳ではないので、例えばループが 2 回実行されても 200 回実行されても使用メモリは同じになります。)これは、 Unity の C# コンパイラによって生成された IL が、値のコレクションを反復するために、汎用の値型 Enumerator を作るからです。
この Enumerator は IDisposable
インターフェースを実装しますが、これはループ終了時に必ず呼び出される必要があります。ただし、値型オブジェクト (構造体や Enumerator など) でインターフェースメソッドを呼び出す場合は、それらのボックス化が必要となります。
以下は、ごく簡単な参考スクリプトです。
int accum = 0;
foreach(int x in myList) {
accum += x;
}
上記が Unity の C# コンパイラーで実行された場合、次の IL を作成します。
.method private hidebysig instance void
ILForeach() cil managed
{
.maxstack 8
.locals init (
[0] int32 num,
[1] int32 current,
[2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_2
)
// [67 5 - 67 16]
IL_0000: ldc.i4.0
IL_0001: stloc.0 // num
// [68 5 - 68 74]
IL_0002: ldarg.0 // this
IL_0003: ldfld class [mscorlib]System.Collections.Generic.List`1<int32> test::myList
IL_0008: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0/*int32*/> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
IL_000d: stloc.2 // V_2
.try
{
IL_000e: br IL_001f
// [72 9 - 72 41]
IL_0013: ldloca.s V_2
IL_0015: call instance !0/*int32*/ valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
IL_001a: stloc.1 // current
// [73 9 - 73 23]
IL_001b: ldloc.0 // num
IL_001c: ldloc.1 // current
IL_001d: add
IL_001e: stloc.0 // num
// [70 7 - 70 36]
IL_001f: ldloca.s V_2
IL_0021: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
IL_0026: brtrue IL_0013
IL_002b: leave IL_003c
} // .try 終了
finally
{
IL_0030: ldloc.2 // V_2
IL_0031: box valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
IL_0036: callvirt instance void [mscorlib]System.IDisposable::Dispose()
IL_003b: endfinally
} // finally 終了
IL_003c: ret
} // method test::ILForeach 終了
} // class test 終了
最も注目すべきコードは最後のほうにある __finally { … }__
ブロックです。 callvirt
命令は、メモリ内の IDisposable.Dispose
メソッドの場所を確認してからこのメソッドを呼び出します。それには Enumerator のボックス化を必要とします。
基本的に Unity では foreach
ループは避けることが推奨されます。 foreach
ループはボックス化を発生させますし、通常は、 Enumerator によるコレクションの反復に掛かる呼び出しコストのほうが、 for
や while
ループによる反復に比べて大幅に低くなります。
Unity 5.5 では C# コンパイラがアップグレードされ、 Unity の IL 生成能力が大幅に向上されています。具体的には foreach
ループからボックス化処理が除去されています。これにより foreach
ループ関連のメモリ オーバーヘッドが無くなります。ただし、 Array ベースのコードと比較した場合の CPU パフォーマンスの違いは、関数呼び出しオーバーヘッドのために依然として残っています。
意図せず起こる配列割り当ての原因としてより分かりにくく面倒なのは、配列を戻す Unity API への頻繁なアクセスです。配列を戻す全ての Unity API は、アクセスされる度にその配列の新しいコピーを 1 つ生成します。必要以上に配列型の Unity API にアクセスするのは非常に不適切です。
例えば、以下のコードでは、ループが 1 回反復するごとに vertices
配列の複製が 4 つ作成されてしまいます。 .vertices
プロパティへのアクセスがある度に割り当てが発生します。
for(int i = 0; i < mesh.vertices.Length; i++)
{
float x, y, z;
x = mesh.vertices[i].x;
y = mesh.vertices[i].y;
z = mesh.vertices[i].z;
// ...
DoSomething(x, y, z);
}
これは、ループに入る前に vertices
配列をキャプチャーすることで、ループの反復回数に関わらず、簡単に単一の配列割り当てにリファクタリングできます。
var vertices = mesh.vertices;
for(int i = 0; i < vertices.Length; i++)
{
float x, y, z;
x = vertices[i].x;
y = vertices[i].y;
z = vertices[i].z;
// ...
DoSomething(x, y, z);
}
1 つのプロパティーに 1 回アクセスするための CPU 負荷はそれほど高くはありませんが、タイトなループ内での頻繁なアクセスは CPU パフォーマンスのホットスポットを発生させます。さらに、頻繁なアクセスはマネージヒープを必要以上に拡張させます。
この問題はモバイルで非常に頻繁に見られます。理由は Input.touches
API の挙動が上記と類似しているからです。以下のようなコード (.touches
プロパティーへのアクセスがある度に割り当てが発生する) がプロジェクトに含まれることは非常に一般的です。
for ( int i = 0; i < Input.touches.Length; i++ )
{
Touch touch = Input.touches[i];
// …
}
これは当然、配列割り当てをループの外に書くことで改善できます。
Touch[] touches = Input.touches;
for ( int i = 0; i < touches.Length; i++ )
{
Touch touch = touches[i];
// …
}
しかし今では、メモリ割り当てを発生させない Unity API のバージョンが多数あります。可能な場合は、そのようなバージョンを使用することをお勧めします。
int touchCount = Input.touchCount;
for ( int i = 0; i < touchCount; i++ )
{
Touch touch = Input.GetTouch(i);
// …
}
上記の例は、割り当ての発生しない Touch API に簡単に変換できます。
プロパティアクセス( Input.touchCount
)は依然としてループの外にあります。これは、このプロパティの get
メソッドを実行するための CPU コストを節約するためです。
開発チームによっては、配列型メソッドが空のセットを戻す必要がある場合に、 null
ではなく空配列を戻す方法を選ぶこともあります。このコーディングのパターンは、多くのマネージ言語、特に C# と Java で一般的に使用されています。
基本的に、メソッドから長さ 0 の配列を返す場合、空配列を繰り返し作成するよりも、長さ 0 の配列の事前に割り当てられたシングルトンなインスタンスを戻すほうが格段に効率的です (5) (__[注]__ 当然ながら、配列が戻されてからリサイズされた場合は例外として扱います)。
脚注
(1) これは、ほとんどのプラットフォームにおいては GPU メモリからのリードバックが極端に遅いためです。 CPU コードで使用するためにテクスチャを一時バッファに読み込む (例えば Texture.GetPixel
)は非常に非効率的です。
(2) 厳密に言うと、 null でない全ての参照型オブジェクトおよびボックス化された全ての値型オブジェクトは、マネージヒープに割り当てられる必要があるということになります。
(3) 正確なタイミングはプラットフォームによって異なります。
(4) これは、特定のフレーム中で一時的に割り当てられたバイトの合計数と同一ではありません。 “GC Alloc” は、特定のフレーム内で割り当てられたバイト数を、後続のフレームで再使用されるものも含めて示すものです)。「Deep Profiling」のオプションが有効になっていれば、そういった割り当てが起こるメソッドの特定が可能です。
(5) 当然ながら、配列が返されてからリサイズされた場合は例外が発生します。