Version: 2019.3
言語: 日本語
アセットの監査
文字列とテキスト

マネージヒープ

Unity 開発者がしばしば直面する問題の 1 つにマネージヒープの予期せぬ拡張があります。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 ビットのプログラムの場合はアドレス空間が十分大きいので、プログラムの実行時間が人間の平均寿命を超えるものでなければ、これが起こる可能性は極めて低くなります。

一時割り当て

各フレームで、何十、何百キロバイトという一時データがマネージヒープに割り当てられて実行されている 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」列を使用して、ユーザーが作成したスレッドで発生するマネージアロケーションを測定することはできません。そのため、デバッグのためにコードの実行を別のスレッドからメインスレッドに切り替えたり、BeginThreadProfiling API を使用して Timeline Profiler にサンプルを表示します。

常に、マネージアロケーションはターゲットデバイスの開発ビルドで検証してください。

スクリプトメソッドによっては、エディターで実行される場合に割り当てを発生させ、プロジェクトのビルド後には割り当てを発生させないものもあります。最も一般的な例は GetComponent です。このメソッドはエディターで実行される際には常に割り当てを行いますが、ビルドされたプロジェクトでは行いません。

基本的には、プロジェクトがインタラクティブな状態にある部分では常に、マネージヒープの割り当てを極力削減することが強く推奨されます。シーン読み込みなどの非インタラクティブ処理においては、これはそれほど問題にはなりません。

Visual Studio の Jetbrains Resharper Plugin は、コードの割り当ての配置をすることができます。

マネージアロケーションの特定の理由を見つけるには、Unity の Deep Profile モードを使用します。Deep Profile モードでは、すべてのメソッドの呼び出しが個別に記録され、マネージアロケーションがメソッド呼び出しツリー内のどこで発生するかがより明確になります。 ディーププロファイリングモードは、エディターだけでなく、コマンドライン引数 -deepprofiling を使って Android やデスクトップでも使用できます。プロファイル中には [Deep Profiler] ボタンがグレー表示されたままになります。

メモリの基本的な節約

マネージヒープの割り当てを削減する比較的簡単な方法がいくつかあります。

Collection と配列の再利用

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 下の匿名メソッド

現段階で、 IL2CPP によって生成されたコードを調べて分かるのは、 System.Function 型の変数の基本的な宣言と指定によって、新しいオブジェクトが割り当てられるということです。これは、変数が明示的 (メソッドあるいはクラス内で宣言されている) か、暗示的 (他のメソッドへ引数として宣言されたもの) であるかに関わらず言えることです。

このように、 IL2CPP スクリプトバックエンド下の匿名メソッドの使用は、常にマネージメモリを割り当てます。Mono スクリプトバックエンド下ではこれは当てはまりません。

さらに IL2CPP は、メソッド引数の宣言方法によってマネージメモリの割り当ての度合いが大幅に変化します。クロージャは、期待通り 1 回の呼び出しにつき最も多くのメモリを割り当てます。

意外なことに、事前定義されたメソッドは、 IL2CPP スクリプトバックエンド下で引数として渡された場合、クロージャにほぼ匹敵する量のメモリ を割り当てます。ヒープに生成される一時的ガベージの量が最も少ないのは匿名メソッドで、他と比べて 1 桁または 2 桁以上の違いがあります。

したがって、プロジェクトが IL2CPP スクリプトバックエンドで配信 (販売) される予定である場合には、推奨される重要事項が 3 つあります。

  • メソッドを引数として渡す必要のないコーディングスタイルを使用する。

  • それが不可避な場合は、なるべく名前付きメソッドではなく匿名メソッドを使用する。

  • スクリプトバックエンドの種類に関わらず、クロージャを避ける。

ボックス化

Unity プロジェクトで、一時メモリの意図せぬ割り当ての原因として最もよくあるもののひとつが、ボックス化です。これは、値型の値が参照型として使用されると起こります。最もよくあるのが、プリミティブな値型変数( intfloat など)をオブジェクト型メソッドに渡す場合です。

以下は、ごく簡単な例です。 x 内の整数が、 object.Equals メソッドに渡されるためにボックス化されています。これは、 objectEquals メソッドに、 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

ボックス化するよくある原因のひとつは、 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 のコンストラクターに渡すことも可能です。

Foreach ループ

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 経由で繰り返しメソッド呼び出しを行うことは、forwhile ループによる手動の繰り返しに比べて一般的に大幅に遅いからです。

Unity 5.5 では C# コンパイラーがアップグレードされ、 Unity の IL 生成能力が大幅に向上されています。具体的には foreach ループからボックス化処理が除去されています。これにより foreach ループ関連のメモリ オーバーヘッドが無くなります。ただし、 Array ベースのコードと比較した場合の CPU パフォーマンスの違いは、関数呼び出しオーバーヘッドのために依然として残っています。

配列型 Unity API

意図せず起こる配列割り当ての原因としてより分かりにくく面倒なのは、配列を返す 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) これは、特定のフレーム中で一時的に割り当てられたバイトの合計数と同一ではありません。プロファイルは、特定のフレームで割り当てられたバイト数を表示します (その一部、または全部が後続のフレームで再使用される場合があります)。

  • (5) 当然ながら、配列が返されてからリサイズされた場合は例外が発生します。


  • 2018–03–05 修正されたページ
アセットの監査
文字列とテキスト