- 概要
- 環境
- IReadOnlyList の アロケーション発生ポイント
- Unity Test Runnerによるアロケーション確認
- サンプルコード
- 参考
- 雑感
概要
今回はIReadOnlyListと アロケーションについて深堀りしようと思います。
前回の記事でも述べましたが、IReadOnlyListはアロケーションを回避したい場合は注意が必要なinterfaceになっていたりします。
そんなIReadOnlyListのアロケーションについて調べたので共有します。
IReadOnlyListについては前回の記事で少し紹介していますので合わせてどうぞ!
環境
- Unity 2021.2.2f1
- Mono
- .Net Framework (API Comptibility Level)
IReadOnlyList の アロケーション発生ポイント
foreach使用時などのIEnumerable.GetEnumerator
ListをIReadOnlyList に変換してforeachに回すと使用するたびにアロケーションが発生します。
List<Foo> list = new List<Foo>(); foreach (var item in list) { } // アロケーションは発生しない IReadOnlyList<Foo> readonlyList = list; foreach (var item in readonlyList) { } // アロケーションが発生する
アロケーションの原因
Listの場合はEnumerable(値型)のboxingが原因です。
public Enumerator GetEnumerator() { return new Enumerator(this); }
list.cs,569 より
ListのGetEnumeratorが返すEnumerableは値型のため、Listを直接使用する場合はアロケーションは発生しません。
一方で、IReadOnlyListに変換した場合のGetEnumeratorはIEnumerableとしてEnumerableを返します。
IEnumerator<T> IEnumerable<T>.GetEnumerator() {
return new Enumerator(this);
}
list.cs,574 より
この戻り値のEnumerableからIEnumerableは値型から参照型への変換、つまりboxingが発生するためアロケーションが発生します。
アロケーションの回避方法
foreach ではなく for を使用すれば回避可能です。
for (int i = 0; i < readonlyList.Count; ++i) { var item = readonlyList[i]; }
IEnumerableと異なりIReadOnlyListはlist[i]が配列の要素に直接アクセスする形となっています。
ListをIEnumerable(or IReadOnlyCollection)に変換してしまうとforが使用不可でGetEnumeratorによるアロケーションは避けられなくなりますが
IReadOnlyListへの変換であれば読込専用を保ちつつforが使用可能なため結果的にアロケーションの回避が可能です。
コーディングの煩わしさはありますが、メモリ的には優しくなります。
IEquatable<T>を実装しない値型(struct)でEquals
IEquatable<T>を継承していない値型はEqualsの挙動によりアロケーションが発生するパターンが多いです。
IReadOnlyListとは直接は関係ありませんが、後述するLinq.Enumerable.Containsに関わってくるため紹介します。
public struct SimpleData { public int value; }
アロケーションの原因
C#の型に定義されているValueType.Equals(Object)は引数にobject型を受け取ります。
つまり、このEqualsに値型を渡すと値型から参照型への変換でboxingが発生しアロケーションに繋がります。
アロケーションの回避方法
主に2つあります。
- structに
IEquatable<T>を継承して実装Equals(T value)で型指定となるためboxingが発生しない
IEqualityComparer<T>を継承したオブジェクトを実装して使用Equals(T x, T y)で型指定となるためboxingが発生しない
public struct EquatableData : IEquatable<EquatableData> { public int value; public bool Equals(EquatableData other) { return this.value == other.value; } ... }
IReadOnlyListでLinq.Enumerable.Contains
using System.Linq;
...
readonlyList.Contains(item);
IReadOnlyListは残念ながらContainsメソッドを持っていません(List.Containsは定義されている)。
拡張メソッドLinq.Enumerable.Containsを使用することで同様の機能を使用できます。
しかし、Linq.Enumerable.Containsはアロケーションが発生するパターンが多いです。
アロケーションの回避方法
Linq.Enumerable.Contains のアロケーションは細かい話が多いため先に回避方法を紹介します。
これに関してはIReadOnlyList向けのContainsを実装する必要がありました。
後述する「アロケーションの原因」を アロケーション回避の検証も含めUnitTestも作成しています。
余談ですが、汎用性を保つためwhere T : structのような制約は入れていません。
その代わりtypeof(T).IsValueTypeによる条件分岐が入っています。
アロケーションの原因
共変性変換のIReadOnlyListに対してLinq.Enumerable.Contains
「共変性を使用したIReadOnlyList」とは要するにList<Foo> -> IReadOnlyList<IFoo>みたいな変換のことです。
共変性については前回の記事を参照。
ポイントは 共変性を利用して変換したIReadOnlyList にあります。
実は List<Foo> -> IReadOnlyList<Foo> のように不変(TをFooのまま変換)の場合はアロケーションは発生しません。
共変性変換の場合にアロケーションが発生する要因はLinq.Enumerable.Containsの定義にあります。
public static bool Contains<TSource>(this IEnumerable<TSource> source, TSource value) { ICollection<TSource> collection = source as ICollection<TSource>; // ICollectionは不変性のため、IReadOnlyList<IFoo>へのキャストは不可のためnullを返す if (collection != null) return collection.Contains(value); return Contains<TSource>(source, value, null); } public static bool Contains<TSource>(this IEnumerable<TSource> source, TSource value, IEqualityComparer<TSource> comparer) { if (comparer == null) comparer = EqualityComparer<TSource>.Default; if (source == null) throw Error.ArgumentNull("source"); foreach (TSource element in source) // <- GetEnumerator使用によりboxingによるアロケーションが発生 if (comparer.Equals(element, value)) return true; return false; }
ICollection<T>は共変性(out T)を持たず不変性のため、List<Foo> -> ICollection<IFoo>は不可となります。
よって IReadOnlyList<IFoo> -> ICollection<IFoo>の変換も不可のため、上記Containsのコードでsource as ICollection<TSource>はnullを返します。
その後foreachにたどり着き、「foreachなどIEnumerableを必要とする処理」で述べたアロケーションが発生します。
逆にIReadOnlyList<Foo>は不変性を満たすためICollection<Foo>にキャストができforeachに到達しないためアロケーションが発生しません。
なんともややこしい。。。
IEquatable<T>非継承のstructを型とするListでLinq.Enumerable.Contains
IEquatable<T>非継承のstructをLinq.Enumerable.Containsに使用した場合はアロケーションが発生します。
private static EqualityComparer<T> CreateComparer() { ... if (typeof(IEquatable<T>).IsAssignableFrom(t)) { return (EqualityComparer<T>)RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(GenericEqualityComparer<int>), t); } ... return new ObjectEqualityComparer<T>(); }
IEquatable<T>継承の場合は型指定のEqualityComparer<T>が生成されますが、
IEquatable<T>非継承の場合はObjectEqualityComparer<T>が使用されてboxingが発生するんですね。
IEqualityComparer<T>指定有りでLinq.Enumerable.Contains
Linq.Enumerable.Containsはオーバーロードで第2引数にIEqualityComparer<T>指定可能なメソッドがあります。
Equalsのboxingは回避できるんですが、残念ながらその後で使用されるforeachのboxingは回避できません。。。
「アロケーションの回避方法」にて記載したコードには、IEqualityComparer<T>指定可能なContainsも実装して記載しています。
Unity Test Runnerによるアロケーション確認
今回のアロケーション有無の検証ですがUnity Test Runnerを利用しました。
実装開始時はIs.AllocatingGCMemory()とIs.Not.AllocatingGCMemory()を使い分けていましたが、
再利用性(TestBaseクラスからの派生)と視認性のためにIs.Not.AllocatingGCMemory()指定で統一しました。
以下の例だと✅でアロケーション発生無し、🚫でアロケーション発生有りという見方になります。
テストコード的にはおかしいですが、一旦視認性を重視しました(別の視認性が確保しやすい方法が思いついたら修正しておきます)。
また、1st, 2ndはインスタンスごとの初回と2回目でアロケーションの変化があるかを確認するために入れています。
List<Foo>

List<Foo> -> IReadOnlyList<Foo>
foreach のみで発生

List<Foo> -> IReadOnlyList<IFoo>
foreach および 共変性変換のためLinq.Enumerable.Containsで発生

List<SimpleData> -> IReadOnlyList<SimpleData>
IEquatable非実装のstruct

List<EquatableData> -> IReadOnlyList<EquatableData>
IEquatable実装のstruct

サンプルコード
IReadOnlyList向けContainsなども含む
追記
Package Manager対応版を用意しました。
参考
- Unity - Scripting API: AllocatingGCMemoryConstraint
- 【Unity】指定のコードがGCを発生させるかどうかをテストする(AllocatingGCMemory) - テラシュールブログ
- テストでのアロケーションの確認方法
雑感
世間ではFacebookからMetaへと社名変更、新しいOculusが発表、メタバースへの注目など、
XR界隈で目まぐるしい変化が来そうなときに、
「なんでXRとは程遠いC#の話してるの?」って言われそうですねw。
アプリケーションの楽しさに直接関連するものでは確かに有りませんが、
一方で快適なXRアプリケーションの実現において、過剰なアロケーションの回避や削減は大事な要素であると考えています。
もちろんUnityにおいて完全にアロケーションを避けることは難しいため、
こだわり過ぎず、避けれるなら避けるぐらいがちょうど良いのではと思います。
マニアックな話では有りましたが、少しでも面白いと思っていただけたなら幸いです。
それでは~