♨️

ImmutableArray<T>の内部配列を参照して高速化

2023/08/15に公開

ImmutableArray<T>の内部配列を参照して高速化

ImmutableArray<T>は不変性が担保(普通に使用している場合)された配列型としてとても重宝しますが、一方でstructとして定義されているために通常の配列と同じ感覚でLINQ等のIEnumerable<T>型の引数などに使用しようとするとstructをIEnumerable<T>型に代入するための暗黙的なボックス化が発生し、僅かとはいえ余計なコストが発生することがあります。

極限までパフォーマンスを気にする場合は、ImmutableArray<T>型か適切な型制約のジェネリック型で自前のメソッドを定義し最適な処理を実装するのがベストですが、ここでは自分で最適な実装を用意するコストは避けて、

  • 単純にボックス化に掛かるコストを回避する
  • 通常の配列等に対して用意されている既存の高速な処理をImmutableArray<T>に対して活用する

方法として、ImmutableArray<T>からほぼ0コストで内部の配列(T[])を取得する方法を2つ紹介します。

方法1 Unsafe.As(...)を利用した取得

Unsafeクラスを利用すると以下のような拡張メソッドを定義することができます。

static class MyImmutableExtensions
    private struct ImmutableArrayInternalReader<T>
    {
        public T[]? array;
    }

    // タイトル通り配列型のまま返すことも可能ですが、不必要に配列型を返すと
    // 呼び出し元の誤りでImmutableArray<T>の内容を変更してしまうやっかいなバグの
    // 温床なるのでここではIReadOnlyList<T>型で返す拡張メソッドとして定義します。
    public static IReadOnlyList<T>? BoxlessAsReadOnlyList<T>(this ImmutableArray<T> immutableArray)
    {
        // ImmutableArray<T>がdefaultの時のarrayはnull
        T[]? array = Unsafe.As<ImmutableArray<T>, ImmutableArrayInternalReader<T>>(ref immutableArray).array;
	return (IReadOnlyList<T>?)array;
    }
}

ImmutableArray<T>型は内部にT[]型のフィールドを一つのみを持つ構造体として定義されています。Unsafeクラスを利用すればImmutableArray<T>型として確保されているメモリ領域を自前で定義した型に置き換えて読み取ることができるため、本来はprivateな内部フィールドにアクセスして読みだすことができます。

この方法は非常に高速ですが、万が一でもImmutableArray<T>に追加のフィールドが置かれるなどの改修が発生してImmutableArray<T>のメモリ領域のレイアウトが変わると機能しなくなり実行時に深刻なエラーに陥いるなどのリスクが存在していることに注意が必要です。また、structとしてのレイアウトが変わらなくとも、例えばパフォーマンス等の理由でImmutableArray<T>の表面上のサイズよりも大きな配列が内部的に確保されるような改修がされてしまうことがあった場合にも正常に動作しなくなってしまいます。実際のところImmutableArray<T>は既に現在の形が完成してから長期間経っており、今更内部のarrayより手前に新しいフィールドが追加されたりするなどの大きな変更が突発的に発生する可能性は少ないと思います

方法2 AsMemory()経由での取得

ImmutableArray<T>に定義されているAsMemory()メソッドを利用して以下のようにすると元のImmutableArray<T>の内部配列を方法1よりは安全かつ合法?に取得することができます。

  • ImmutableArray<T>に対してAsMemory()を呼び出してMemory<T>を取得します。
  • MemoryMarshal.TryGetArray<T>(ReadOnlyMemory<T>, out ArraySegment<T>)を使用してMemory<T>からArraySegment<T>を取得します。
  • ArraySegment<T>ArrayプロパティからImmutableArray<T>の内部配列が取得できます。

この手法を少し肉付けして方法1と同様に拡張メソッド化すると以下のような感じになります。

static class MyImmutableExtensions
{
    public static IReadOnlyList<T> BoxlessAsReadOnlyList<T>(this ImmutableArray<T> immutableArray)
    {
        if (immutableArray.IsDefaultOrEmpty) return Array.Empty<T>();

        if (MemoryMarshal.TryGetArray(immutableArray.AsMemory(), out var innerArray) && innerArray.Array?.Length == immutableArray.Length)
        {
            T[] array = innerArray.Array;
            return (IReadOnlyList<T>)array;
        }
        else
        {
            Debug.Fail("ImmutableArrayの内部配列の取得に失敗");
            return (IReadOnlyList<T>)immutableArray;
        }
    }
}

この方法の場合、仮にImmutableArray<T>の内部実装が大幅に変わり、MemoryMarshal.TryGetArray<T>(...)によってImmutableArray<T>の内部配列が取れなくなったとしても実行時エラーとなる心配はありません。また、もし適切な内部配列が取れなければImmutableArray<T>のボックス化によってIReadOnlyList<T>を返す処理にフォールバックさせることができます。

内部配列を利用することによるパフォーマンスへの影響

本記事で紹介する手法がパフォーマンスに影響する例としてintの配列に対してSum()拡張メソッドを呼び出した場合のベンチマーク結果を紹介します。
測定対象は以下の4パターンです。測定コード全体は本記事の末尾に添付します。

Method 測定の対象とする実装
Array 通常のint配列に対してSum()
ImmutableArray_Default ImmutableArray<int>に対して直接Sum()
ImmutableArray_UnsafeAsImpl ImmutableArray<int>を方法1の手法で配列化(表面上の型はIReadOnlyList<T>)してSum()
ImmutableArray_AsMemoryImpl ImmutableArray<int>を方法2の手法で配列化(表面上の型はIReadOnlyList<T>)してSum()

測定結果(.NET6)

Method Mean Error StdDev Gen0 Allocated
Array 195.1 us 1.50 us 1.41 us 24.4141 312.5 KB
ImmutableArray_Default 226.2 us 0.86 us 0.67 us 42.7246 546.88 KB
ImmutableArray_UnsafeAsImpl 191.4 us 1.24 us 1.16 us 24.4141 312.5 KB
ImmutableArray_AsMemoryImpl 207.3 us 1.50 us 1.33 us 24.4141 312.5 KB

測定結果(.NET7)

Method Mean Error StdDev Gen0 Allocated
Array 33.38 us 0.315 us 0.295 us - -
ImmutableArray_Default 242.25 us 2.266 us 2.009 us 42.4805 560000 B
ImmutableArray_UnsafeAsImpl 37.01 us 0.143 us 0.127 us - -
ImmutableArray_AsMemoryImpl 68.60 us 0.430 us 0.402 us - -

.NET6の場合、全体として顕著な違いは現れませんが、ImmutableArray<int>に対して直接Sum()を呼び出す場合のみが実行速度とアロケーションにおいて若干劣っており、ボックス化によるメモリアロケーションが余分に発生していることが分かります。

.NET7の場合、ImmutableArray<int>に対して直接Sum()を呼び出す場合のみが.NET6と大きな違いがない一方で、配列に対してSum()を呼び出す場合とImmutableArray<int>に対して本記事で紹介する手法で内部配列を取得してからSum()を呼び出す場合は.NET6と比較して圧倒的に高速化している上に完全なゼロアロケーションで実行できていることが分かります。これは.NET7でLINQ系の拡張メソッドに大幅なパフォーマンス改善(.NET Blog)が行われたことによる違いです。.NET7の一部のメソッドは呼び出し元が配列である場合などに従来と比べて圧倒的に高速な実行がされるような最適化がされています。そして、本記事で紹介するする手法を用いると、ImmutableArray<T>に対しても配列向けに最適化された処理で実行されるようになるため、ImmutableArray<int>に対して直接Sum()を呼び出すよりも圧倒的に高速化されるようになります。

まとめと補足

本記事では、ImmutableArray<T>の内部配列を参照して利用する方法を紹介しました。ImmutableArray<T>の内部配列が参照出来ると、IEnumerable<T>型などの引数を持つ既存APIに対してボックス化のコストを避けてImmutableArray<T>を渡すことができるようになるメリットがあります。また、配列などに対して特別な最適化をもつ既存APIがある場合、ImmutableArray<T>でも配列と同様の処理を受けることができるようになり、自前でImmutableArray<T>用に最適化した実装を用意することなく大幅な高速化につなげられる場合もあります。

なお、ImmutableArray<T>の内部配列を参照してして利用することは誤った取り扱いをするとImmutableArray<T>の不変性を破壊する危険があります。本記事のサンプルコードでも意図的にT[]でなくIReadOnlyList<T>を取得する拡張メソッドとして本手法を実装しており、コピー&ペーストで使用しても事故が起こりにくいようにしていますが、よくご注意ください。

ベンチマークコード

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace PerformanceTest
{
    [MemoryDiagnoser]
    public class PerformanceTest
    {
        const int N = 10000;

        private readonly int[] array;

        private readonly ImmutableArray<int> immutableArray;


        public PerformanceTest()
        {
            array = new[]
            {
                3, 5, 8, 1
            };

            immutableArray = ImmutableArray.Create<int>(4, 3, 2, 4);
        }

        [Benchmark]
        public int Array()
        {
            int sum = 0;
            for (int i = 0; i < N; i++)
            {
                sum += array.Sum();
            }
            return sum;
        }

        [Benchmark]
        public int ImmutableArray_Default()
        {
            int sum = 0;
            for (int i = 0; i < N; i++)
            {
                sum += immutableArray.Sum();
            }
            return sum;
        }

        [Benchmark]
        public int ImmutableArray_AsMemoryImpl()
        {
            int sum = 0;
            for (int i = 0; i < N; i++)
            {
                sum += immutableArray.BoxlessAsReadOnlyList_AsMemoryImpl().Sum();
            }
            return sum;
        }

        [Benchmark]
        public int ImmutableArray_UnsafeAsImpl()
        {
            int sum = 0;
            for (int i = 0; i < N; i++)
            {
                sum += immutableArray.BoxlessAsReadOnlyList_UnsafeAsImpl()!.Sum();
            }
            return sum;
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run(typeof(Program).Assembly);
        }
    }
}

static class MyImmtuableArrayExtensions
{

    public static IReadOnlyList<T> BoxlessAsReadOnlyList_AsMemoryImpl<T>(this ImmutableArray<T> immutableArray)
    {
        if (immutableArray.IsDefaultOrEmpty) return Array.Empty<T>();

        if (MemoryMarshal.TryGetArray(immutableArray.AsMemory(), out var innerArray) && innerArray.Array?.Length == immutableArray.Length)
        {
            return innerArray.Array;
        }
        else
        {
            Debug.Fail("ImmutableArrayの内部配列の取得に失敗");
            return (IReadOnlyList<T>)immutableArray;
        }
    }

    struct ImmutableArrayInternalReader<T>
    {
        public T[]? array;
    }

    public static IReadOnlyList<T>? BoxlessAsReadOnlyList_UnsafeAsImpl<T>(this ImmutableArray<T> immutableArray)
    {
        return Unsafe.As<ImmutableArray<T>, ImmutableArrayInternalReader<T>>(ref immutableArray).array;
    }
}

Discussion