マネージド ヒープの予期せぬ拡張も、 Unity デベロッパーがしばしば直面する問題のひとつです。 Unity では、マネージド ヒープは、縮小するよりも遥かに拡張しやすい傾向にあります。さらに、 Unity のガベージコレクションの方式はメモリを断片化しやすく、そのために大きなヒープの縮小が阻まれることがあります。
「マネージド ヒープ」とは、メモリ内で、プロジェクトのスクリプト ランタイムのメモリマネージャー (Mono か IL2CPP)によって自動的に管理(マネージ)されるセクションのことです。マネージド コード内で作成されたオブジェクトは全てマネージド ヒープに割り当てられる必要があります(2)。(__[注]__ 厳密には、 null でない参照型オブジェクトの全てとボックス化された値型オブジェクトの全てが、マネージド ヒープに割り当てられる必要があります。)
上の図をご覧ください。白いボックスはマネージド ヒープに割り当てられたメモリ量を示しており、中にある色付きボックスが、マネージド ヒープのメモリ領域内に格納されたデータの値を示しています。追加の値が必要になると、マネージド ヒープ内から追加領域が割り当てられます。
ガベージコレクターは周期的に実行されます (3) ([注] 正確なタイミングはプラットフォームによって異なります)。これにより、ヒープ上の全てのオブジェクトが一斉調査され、既に参照されなくなっている全てのオブジェクトが選別されます。その後、参照の外されたオブジェクトが削除され、メモリ領域が解放されます。
ここで重要なことは、Unity のガベージコレクションが Boehm GC アルゴリズム を使用しており、非世代別で非圧縮型であることです。「非世代別」とは、コレクションを 1 回実行するごとに GC がヒープ全体を一斉調査しなければならないことを意味しており、このため、ヒープが拡張するに応じてパフォーマンスが劣化します。「非圧縮型」とは、オブジェクト同士の隙間を埋めるためにメモリ内のオブジェクトが移動されないことを意味します。
上の図はメモリの断片化の一例を示しています。特定のオブジェクトが解放されると、そのメモリが解放されます。しかし、解放された領域は、まとまったひとつの大きな「空の領域」プールの一部にはなりません。解放されたオブジェクトの隣にあるオブジェクトはまだ使用されている可能性があります。このため、解放された領域は、メモリ内の別のセグメント同士の間の「隙間」になります。(上図内では赤丸の部分がこの隙間を表しています。)したがって、新しく解放された領域は、解放されたオブジェクトと同じか、それ以下のサイズのデータの格納にしか使用できません。
オブジェクトを割り当てる際は、オブジェクトは常にメモリ内において、まとまったひとつの領域を占有しなければならないことにご注意ください。
このことが、メモリ断片化の核心的な問題に繋がっています。ヒープ内の使用可能領域の合計量が大きくても、その領域の一部または全体が、割り当てられたオブジェクト間の小さな「隙間」の中にある可能性があります。この場合、合計サイズで見ると特定の割り当てを行うのに十分な空き領域があったとしても、マネージドヒープは、実際にそれを行うために十分な大きさの一塊のメモリを見付けられないということになります。
しかし、上図のように、大きなオブジェクトが割り当てられ、そのオブジェクトに対応できる十分なサイズの空き領域がある場合は、 Unity のメモリマネージャーは 2 つの処理を実行します。
まず、(まだ行われていない場合は)ガベージコレクションが実行されます。これは、割り当てリクエストに応えるために十分な領域を解放するために行われます。
もしガベージコレクション実行後にまだリクエストされた量のまとまったメモリ領域がない場合、ヒープは拡張しなければなりません。具体的な拡張量はプラットフォームによって異なりますが、ほとんどの Unity プラットフォームでは、マネージドヒープのサイズが 2 倍になります。
マネージドヒープの拡張に関する主な問題は、二つの要素から成るものです。
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) (__[注]__これは、特定のフレーム中で一時的に割り当てられたバイトの合計数と同一ではありません。 “GC Alloc” は、特定のフレーム内で割り当てられたバイト数を、後続のフレームで再使用されるものも含めて示すものです。)。「Deep Profiling」のオプションが有効になっていれば、そういった割り当てが起こるメソッドの特定が可能です。
スクリプトメソッドによっては、エディターで実行された場合には割り当てを発生させ、プロジェクトのビルド後には割り当てを発生させないものもあります。最も一般的な例は GetComponent
です。このメソッドはエディターで実行される際には常に割り当てを行いますが、ビルドされたプロジェクトでは行いません。
基本的には、プロジェクトがインタラクティブな状態にある部分では常に、マネージドヒープの割り当てを極力削減することが強く推奨されます。シーン読み込みなどの非インタラクティブ処理においては、これはそれほど問題にはなりません。
マネージドヒープの割り当てを削減するための比較的簡単な方法がいくつかあります。
C# の Collection クラスや配列を使用する場合は、可能な限り、割り当てられた 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 が拡大される必要のある時にのみ割り当てられます。
クロージャおよび匿名メソッドを使用する場合には考慮すべき点が二つあります。
まず第一に、 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# コードを書く場合には、ボック化は極力避けるようにしてください。
ボックス化は CPU 出力内では、特定のいくつかのメソッドの内のどれか(使用中のスクリプト バックエンドによって異なる)に対する呼び出しとして表示されます。これらのメソッドは通常、次のどれかの形式を取ります。 <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
} // end of .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
} // end of finally
IL_003c: ret
} // end of method test::ILForeach
} // end of 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) 当然ながら、配列が戻されてからリサイズされた場合は例外として扱います。