すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【Unity C#】 IReadOnlyListとアロケーション

概要

今回はIReadOnlyListアロケーションについて深堀りしようと思います。

前回の記事でも述べましたが、IReadOnlyListアロケーションを回避したい場合は注意が必要なinterfaceになっていたりします。
そんなIReadOnlyListアロケーションについて調べたので共有します。

IReadOnlyListについては前回の記事で少し紹介していますので合わせてどうぞ!

tsgcpp.hateblo.jp

環境

IReadOnlyList の アロケーション発生ポイント

foreach使用時などのIEnumerable.GetEnumerator

ListIReadOnlyList に変換して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 より

ListGetEnumeratorが返すEnumerableは値型のため、Listを直接使用する場合はアロケーションは発生しません。

一方で、IReadOnlyListに変換した場合のGetEnumeratorIEnumerableとして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と異なりIReadOnlyListlist[i]が配列の要素に直接アクセスする形となっています。

list.cs,1107

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型を受け取ります。

docs.microsoft.com

つまり、この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アロケーションが発生するパターンが多いです。

docs.microsoft.com

アロケーションの回避方法

Linq.Enumerable.Containsアロケーションは細かい話が多いため先に回避方法を紹介します。
これに関してはIReadOnlyList向けのContainsを実装する必要がありました。

github.com

後述する「アロケーションの原因」を アロケーション回避の検証も含め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;
        }

Enumerable.cs,1364 より

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>();
        }

equalitycomparer.cs,40 より

IEquatable<T>継承の場合は型指定のEqualityComparer<T>が生成されますが、
IEquatable<T>非継承の場合はObjectEqualityComparer<T>が使用されてboxingが発生するんですね。

IEqualityComparer<T>指定有りでLinq.Enumerable.Contains

Linq.Enumerable.Containsオーバーロードで第2引数にIEqualityComparer<T>指定可能なメソッドがあります。
Equalsboxingは回避できるんですが、残念ながらその後で使用されるforeachboxingは回避できません。。。

Enumerable.cs,1370

アロケーションの回避方法」にて記載したコードには、IEqualityComparer<T>指定可能なContainsも実装して記載しています。

Unity Test Runnerによるアロケーション確認

今回のアロケーション有無の検証ですがUnity Test Runnerを利用しました。

github.com

実装開始時はIs.AllocatingGCMemory()Is.Not.AllocatingGCMemory()を使い分けていましたが、 再利用性(TestBaseクラスからの派生)と視認性のためにIs.Not.AllocatingGCMemory()指定で統一しました。

以下の例だと✅でアロケーション発生無し、🚫でアロケーション発生有りという見方になります。
テストコード的にはおかしいですが、一旦視認性を重視しました(別の視認性が確保しやすい方法が思いついたら修正しておきます)。

また、1st, 2ndはインスタンスごとの初回と2回目でアロケーションの変化があるかを確認するために入れています。

List<Foo>

f:id:tsgcpp:20211116022853p:plain

List<Foo> -> IReadOnlyList<Foo>

foreach のみで発生

f:id:tsgcpp:20211116022823p:plain

List<Foo> -> IReadOnlyList<IFoo>

foreach および 共変性変換のためLinq.Enumerable.Containsで発生

f:id:tsgcpp:20211116022932p:plain

List<SimpleData> -> IReadOnlyList<SimpleData>

IEquatable非実装のstruct

f:id:tsgcpp:20211120235310p:plain

List<EquatableData> -> IReadOnlyList<EquatableData>

IEquatable実装のstruct

f:id:tsgcpp:20211120235152p:plain

サンプルコード

github.com

  • IReadOnlyList向けContainsなども含む

追記

Package Manager対応版を用意しました。

github.com

参考

雑感

世間ではFacebookからMetaへと社名変更、新しいOculusが発表、メタバースへの注目など、
XR界隈で目まぐるしい変化が来そうなときに、
「なんでXRとは程遠いC#の話してるの?」って言われそうですねw。

アプリケーションの楽しさに直接関連するものでは確かに有りませんが、
一方で快適なXRアプリケーションの実現において、過剰なアロケーションの回避や削減は大事な要素であると考えています。

もちろんUnityにおいて完全にアロケーションを避けることは難しいため、
こだわり過ぎず、避けれるなら避けるぐらいがちょうど良いのではと思います。

マニアックな話では有りましたが、少しでも面白いと思っていただけたなら幸いです。
それでは~