お久しぶりです
プロジェクトでメモリ削減の調査をしてるときに
Dictionaryから生成されたコードが肥大化してメモリを圧迫していたので
調査メモとしてまとめました
ざっくり結論
IL2CPP+DictionaryはTKeyとTValueのパターンごとに約400KBのコード生成が行われる
できる限りパターンをそろえるか、NativeHashMapやListや独自実装を使うことで
コード生成を避けましょう
導入
UnityのIL2CPPとは
C#コードをC++コードに変換してバイナリを作る技術のこと
これにより実行速度向上・省メモリ化が見込めます
なぜIL2CPP+Dictionaryが危険なのか
C#はリッチな言語で、実行中に型を決定できます ⇦ JIT (Just-In-Time) コンパイラ
C++は事前にすべての型を決めておく必要があります ⇦ AOT (Ahead-Of-Time) コンパイル
このため、C#のジェネリックをC++のテンプレートに落とし込む際、入りうる型引数ごとにコード生成が行われます
特にDictionaryはクラス自体が高機能なのもあり、テンプレートごとに大量のコードが生成されます
Dictionaryはキャッシュ構造の自動生成に使われることも多く
自動生成で何百個も生成した結果100MBに膨らみRAMを占有してしまいます
MemoryProfilerの「Executable & Mapped」を見ながらコード変更して計測してみるとわかります
今回はどういう定義がどれくらい影響するか見ていき
Dictionaryを使う際の注意点を探していきます
調査
確認環境
Windows 11
Unity 6000.0.49f1
具体的な数値変化
下記フォルダの容量を比較していきます
Library/Bee/artifacts/WinPlayerBuildProgram/il2cppOutput/cpp
数値型の場合
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, int> sample1 = new(); // ここまでで 3309 増加
Dictionary<int, int> sample2 = new(); // ここまでで 4086 増加
Dictionary<int, int> sample3 = new(); // ここまでで 4863 増加
Dictionary<int, int> sample4 = new(); // ここまでで 5640 増加
同じ型を何度も定義するにはコードはほぼ増えません
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, int> sample1 = new(); // ここまでで 3309 増加
Dictionary<uint, int> sample2 = new(); // ここまでで 6459 増加
Dictionary<float, int> sample3 = new(); // ここまでで 505503 増加
Dictionary<double, int> sample4 = new(); // ここまでで 825994 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, int> sample1 = new(); // ここまでで 3309 増加
Dictionary<int, uint> sample2 = new(); // ここまでで 495176 増加
Dictionary<int, float> sample3 = new(); // ここまでで 498455 増加
Dictionary<int, double> sample4 = new(); // ここまでで 826016 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, int> sample1 = new(); // ここまでで 3309 増加
Dictionary<float, float> sample2 = new(); // ここまでで 498160 増加
Dictionary<uint, uint> sample3 = new(); // ここまでで 820981 増加
Dictionary<double, double> sample4 = new(); // ここまでで 1436033 増加
型をちょっと変えるだけで結構コード量が増えてます
条件によりますが1定義で400KB増えることを覚悟するべきかも
クラスの場合
// 元の生成コードサイズ: 691328967 Byte
Dictionary<TestClass1, TestClass1> sample1 = new(); // ここまでで 6139 増加
Dictionary<TestClass2, TestClass2> sample2 = new(); // ここまでで 11367 増加
Dictionary<TestClass3, TestClass3> sample3 = new(); // ここまでで 16594 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<TestClass1, int> sample1 = new(); // ここまでで 6099 増加
Dictionary<TestClass2, float> sample2 = new(); // ここまでで 11563 増加
Dictionary<TestClass3, uint> sample3 = new(); // ここまでで 516079 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, TestClass1> sample1 = new(); // ここまでで 6099 増加
Dictionary<float, TestClass2> sample2 = new(); // ここまでで 507464 増加
Dictionary<uint, TestClass3> sample3 = new(); // ここまでで 512927 増加
数値型とほぼ同じ
Enumの場合
// 元の生成コードサイズ: 691328967 Byte
Dictionary<Type1, Type1> sample1 = new(); // ここまでで 6054 増加
Dictionary<Type2, Type2> sample2 = new(); // ここまでで 11197 増加
Dictionary<Type3, Type3> sample3 = new(); // ここまでで 16339 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<Type1, int> sample1 = new(); // ここまでで 488849 増加
Dictionary<Type2, float> sample2 = new(); // ここまでで 818734 増加
Dictionary<Type3, uint> sample3 = new(); // ここまでで 1432765 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, Type1> sample1 = new(); // ここまでで 493146 増加
Dictionary<float, Type2> sample2 = new(); // ここまでで 824622 増加
Dictionary<double, Type3> sample3 = new(); // ここまでで 1452248 増加
数値や参照よりも一気にコード量が増えています
Enumでも増えないパターン
// 元の生成コードサイズ: 691328967 Byte
Dictionary<Enum1, int> sample1 = new(); // ここまでで 488867 増加
Dictionary<Enum2, int> sample2 = new(); // ここまでで 494028 増加
Dictionary<Enum3, int> sample3 = new(); // ここまでで 499188 増加
1つ目は400KBも増えてます
2つ目以降は10KBも増えてません
これはジェネリックコードの再利用が起きてるから
TKeyが違ってもTValueが違ったり、
どの型でも同じメソッドしか使ってなければ
コードの再利用が起きてコード量が爆発的に増えたりはしないらしいです
Structの場合
// 元の生成コードサイズ: 691328967 Byte
Dictionary<Box1, Box1> sample1 = new(); // ここまでで 702468 増加
Dictionary<Box2, Box2> sample2 = new(); // ここまでで 1219690 増加
Dictionary<Box3, Box3> sample3 = new(); // ここまでで 1663662 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<Box1, int> sample1 = new(); // ここまでで 692121 増加
Dictionary<Box2, float> sample2 = new(); // ここまでで 1207416 増加
Dictionary<Box3, uint> sample3 = new(); // ここまでで 1659707 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, Box1> sample1 = new(); // ここまでで 686589 増加
Dictionary<float, Box2> sample2 = new(); // ここまでで 1198307 増加
Dictionary<double, Box3> sample3 = new(); // ここまでで 1639201 増加
Enumよりも生成量が増えてます
これは数値比較がEnumよりも複雑で、Equals() と GetHashCode()の実装が必要なためです
Tupleの場合
// 元の生成コードサイズ: 691328967 Byte
Dictionary<(int, int), (int, int)> sample1 = new(); // ここまでで 522435 増加
Dictionary<(float, float), (float, float)> sample2 = new(); // ここまでで 1078199 増加
Dictionary<(uint, uint), (uint, uint)> sample3 = new(); // ここまでで 1680577 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<(int, int), int> sample1 = new(); // ここまでで 515495 増加
Dictionary<(float, float), float> sample2 = new(); // ここまでで 1063444 増加
Dictionary<(uint, uint), uint> sample3 = new(); // ここまでで 1066594 増加
// 元の生成コードサイズ: 691328967 Byte
Dictionary<int, (int, int)> sample1 = new(); // ここまでで 504080 増加
Dictionary<float, (float, float)> sample2 = new(); // ここまでで 1048925 増加
Dictionary<uint, (uint, uint)> sample3 = new(); // ここまでで 1637353 増加
Structと同じくらい増えています
tupleはValueTuple という構造体なためです
調査結果まとめ
例外はあるものの、
DictionaryはTKeyとTValueのパターンが増えることに
容量がおおよそ400KB増えていきます
Enumをintに変換したり、
データ本体ではなくIDをDictionaryに入れたり、
Tupleを結合してStringにしてからDictionaryに入れたり、
できる限りTKeyとTValueのパターンを使わすことで生成コード量を抑制できます
対策
実際問題、TKeyとTValueのパターンを抑制するのは難しいです
いくつか代替手段を調べたので記載しておきます
NativeHashMap
Unityはよりアンマネージメントで高速なライブラリを提供してます
https://docs.unity3d.com/Packages/com.unity.collections@0.0/api/Unity.Collections.NativeHashMap-2.html
メリット
・高速、IL2CPPでもコードが増えない
デメリット
・定義時にサイズ指定しないといけない
・追加ができない(コストがかかる)
List<Pair<Key, T>>
Listはコード生成してもDictionaryほどのコード生成は走りません
数KB程度です
KeyとValueをPairのListで保持し、取得時にループして検索することで同じことができます
メリット
・軽量、IL2CPPでもコードがほぼ増えない
・読みやすい
デメリット
・要素が増えると探索コストが増える(O(n))
・Uniqueチェックをしてくれない
独自実装
Dictionaryは本当にリッチなので、必要最低限の連想配列ラッパークラスを作るのもよいです
KeyがUniqueである必要があるか、動的追加が必要か、Sort済であるべきか、
サイズ数と取得速度のバランス、メモリと
今後の課題
ジェネリックをテンプレートにする場合にコードが大量生成されるので
本当はDictionaryどうこうではなく他の個所でも起こります
次に注意なのはLinq部分かな?
今度時間があればそっちも調べます
おまけ(メモリ)
MemoryProfilerの「Executable & Mapped」を見ながらコード変更して計測してみるとわかります
普通に考えて、C++コード全部を静的メモリに展開する必要はないので半信半疑です
DictionaryをListに変換することでMemoryProfilerの該当項目は減ったのは確認しましたが
それが実機でも使用メモリ削減できてるかは不明です