0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【コレクション式】スプレッドを逆コンパイルしてみる

Posted at

はじめに

C# 12 / .net8 でコレクション式が導入されました。その機能のひとつにスプレッドがあります。

List<int> a = [1, 2, 3];
List<int> b = [.. a, 4, 5]; // .. a がスプレッド
Assert.Equal([1, 2, 3, 4, 5], b);

文字列補間のコレクション版のような機能です。今回はこれを逆コンパイルしてみて、どのように実装されているか見ていきます。

C# 13 / .net9-preview7 時点

逆コンパイル

上記の例を逆コンパイルした結果が ↓ です。

// メソッドが unsafe になっている
int num = 3;
List<int> list = new List<int>(num);
CollectionsMarshal.SetCount<int>(list, num);
Span<int> span = CollectionsMarshal.AsSpan<int>(list); // リストのキャパシティを設定。内部配列がガベージになることを避ける
int num2 = 0;
*span[num2] = 1; // 通常の C# コードとは異なる書き方。逆コンパイラの仕様かも
num2++;
*span[num2] = 2; // List.Add() ではなくスパンに直接割り当てている
num2++;
*span[num2] = 3;
num2++;
List<int> a = list;
List<int> list2 = a;
num2 = 2 + list2.Count;
List<int> list3 = new List<int>(num2);
CollectionsMarshal.SetCount<int>(list3, num2);
span = CollectionsMarshal.AsSpan<int>(list3);
num = 0;
Span<int> span2 = CollectionsMarshal.AsSpan<int>(list2);
span2.CopyTo(span.Slice(num, span2.Length));
num += span2.Length;
*span[num] = 4;
num++;
*span[num] = 5;
num++;
List<int> b = list3;
num = 5;
List<int> list4 = new List<int>(num);
CollectionsMarshal.SetCount<int>(list4, num);
span2 = CollectionsMarshal.AsSpan<int>(list4);
num2 = 0;
*span2[num2] = 1;
num2++;
*span2[num2] = 2;
num2++;
*span2[num2] = 3;
num2++;
*span2[num2] = 4;
num2++;
*span2[num2] = 5;
num2++;
Assert.Equal<List<int>>(list4, b);

コンパイラが結構仕事をしていることがわかります。List.Add() ではなく直接スパンに割り当てる方式になっており、なんかパフォーマンスが出そうです。

ちなみに
C#12 / .net8 だと List.Add() が呼ばれていたような気がします。バージョンが上がって最適化されたっぽいです。

パフォーマンス比較

スプレッドを使うのと手動で同じ処理をするのとで、パフォーマンスに違いが出るか比較してみます。

p.AddTest("Spread", () =>
{
    for (int n = 0; n < 10000; ++n)
    {
        List<int> a = [1, 2, 3];
        List<int> b = [.. a, 4, 5];
    }
});

p.AddTest("List", () =>
{
    for (int n = 0; n < 10000; ++n)
    {
        List<int> a = [1, 2, 3];
        List<int> b = new List<int>(a.Count + 2);
        b.AddRange(a);
        b.Add(4);
        b.Add(5);
    }
});
Test Score % CG0
x86
Spread 420 100.0% 77
List 346 82.4% 63
x64
Spread 369 100.0% 67
List 280 75.9% 50

実行環境: Windows11
Score は高いほどパフォーマンスがよいです。
GC0 はガベージコレクション回数を表します(少ないほどパフォーマンスがよい)。

割と差が出ているのではないでしょうか。スプレッドのほうが明確にパフォーマンスが良いです。

コレクション式は「書きやすい」だけではなく、「一番パフォーマンスがいい」を目標としているとのことで、基本的にコレクション式・スプレッドを使うのが良さげです。
ちなみに、↑ の下の方の処理は VSCode に電球マーク💡(クイック修正)が出てコレクションの初期化を簡素化できますを実行すると、上の方のコードと全く同じコードを提案してくれます。

コレクション型の違いとパフォーマンス

Test Score % CG0
ToList (4) x86
ListToList 432 100.0% 79
ReadOnlySpanToList 781 180.8% 77
SpanToList 954 220.8% 94
ArrayToList 664 153.7% 96
ToList (4) x64
ListToList 364 100.0% 66
ReadOnlySpanToList 726 199.5% 69
SpanToList 647 177.7% 61
ArrayToList 369 101.4% 56
ToReadOnlySpan (4) x86
ListToReadOnlySpan 651 100.0% 94
ReadOnlySpanToReadOnlySpan 1,667 256.1% 101
SpanToReadOnlySpan 1,594 244.9% 97
ArrayToReadOnlySpan 1,141 175.3% 122
ToReadOnlySpan (4) x64
ListToReadOnlySpan 473 100.0% 67
ReadOnlySpanToReadOnlySpan 1,398 295.6% 80
SpanToReadOnlySpan 987 208.7% 56
ArrayToReadOnlySpan 482 101.9% 54
  • ReadOnlySpan はパフォーマンスが良い傾向があります。これは、一部の組み込み型の読み取り専用コレクションに対してアセンブリのバイト配列を直接読み出す最適化がかかり、ヒープが発生しないためです(静的なデータの ReadOnlySpan 最適化
  • Span もパフォーマンスが良い傾向があります。これもコンパイラがあれこれしてヒープが発生しない最適化がかかります(InlineArray

IEnumerable<T> の場合が奇妙

今回逆コンパイルして気づいたのですが、IEnumerable を作成した場合に奇妙なコードが生成されます。

// スプレッド
IEnumerable<int> a = [1 ,2, 3];

// ↓ 逆コンパイル後
IEnumerable<int> a = new <>z__ReadOnlyArray<int>(new int[]
{
	1,
	2,
	3
});

この <>z__ReadOnlyArray 型はコンパイラが生成するクラスです。通常の C# からは操作できません。中身は単純なラップクラスで、IEnumerable 他のコレクション系のインターフェイスを実装します。

  • internal クラスを経由することで内部配列を隠蔽している?(enumerable as T[] とかでキャストできない)
  • パフォーマンス的には配列をインターフェイス越しに操作するのと同じ

おまけ

List.AddRange() の実装が変わっている!

・・・というのも、以前(少なくとも .NET Framework 時代)は List.AddRange() はあまりいい実装ではなかったのです。

// うろ覚え擬似コード
static void OldAddRange<T>(List<T> list, IEnumerable<T> items)
{
    if (items is ICollection<T> collections)
    {
        list.Capacity += collections.Count;
        var array = new T[collections.Count]; // ここで new T[] されている! つまりヒープが発生
        collections.CopyTo(array, 0);
        foreach (var n in array)
        {
            list.Add(n);
        }
    }
    else
    {
        foreach (var n in items)
        {
            list.Add(n);
        }
    }
}

これがいつの間にかアップデートされていて、かなりパフォーマンスが良くなっています。ついでに List.AddRange(ReadOnlySpan) オーバーロード(拡張メソッド)も追加されています。こちらもパフォーマンスがよろしいです。

Test Score % CG0
x86
List.AddRange 200 100.0% 42
CollectionExtensions.AddRange 275 137.5% 42
OldAddRange 180 90.0% 44
x64
List.AddRange 182 100.0% 38
CollectionExtensions.AddRange 355 195.1% 50
OldAddRange 89 48.9% 22
List.AddRange() の実装
public void AddRange(IEnumerable<T> collection)
{
	if (collection == null)
	{
		ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
	}
	ICollection<T> collection2 = collection as ICollection<!0>;
	if (collection2 != null)
	{
		int count = collection2.Count;
		if (count > 0)
		{
			if (this._items.Length - this._size < count)
			{
				this.Grow(checked(this._size + count));
			}
			collection2.CopyTo(this._items, this._size);
			this._size += count;
			this._version++;
			return;
		}
	}
	else
	{
		foreach (T item in collection)
		{
			this.Add(item);
		}
	}
}
List.AddRange(ReadOnlySpan) の実装
public static void AddRange<[Nullable(2)] T>(this List<T> list, [ParamCollection] [ScopedRef] [Nullable(new byte[]
{
	0,
	1
})] ReadOnlySpan<T> source)
{
	if (list == null)
	{
		ThrowHelper.ThrowArgumentNullException(ExceptionArgument.list);
	}
	if (!source.IsEmpty)
	{
		if (list._items.Length - list._size < source.Length)
		{
			list.Grow(checked(list._size + source.Length));
		}
		source.CopyTo(list._items.AsSpan(list._size));
		list._size += source.Length;
		list._version++;
	}
}

おわりに

スプレッドは C# 12 / .net8 で導入されましたが、C# 13 / .net9 でかなりパフォーマンスが改善されています。このあたりは興味深いので、ちょくちょく追いかけていこうと思います。

テストコード
using Xunit;

public class _SpreadTest
{
    [Fact]
    void HouToUse()
    {
        List<int> a = [1, 2, 3];
        List<int> b = [.. a, 4, 5];
        Assert.Equal([1, 2, 3, 4, 5], b);
    }

    [Fact]
    void ReadOnlySpan()
    {
        ReadOnlySpan<int> a = [1, 2, 3];
        ReadOnlySpan<int> b = [.. a, 4, 5];
        Assert.Equal([1, 2, 3, 4, 5], b);
    }

    [Fact]
    void Span()
    {
        ReadOnlySpan<int> a = [1, 2, 3];
        Span<int> b = [.. a, 4, 5];
        Assert.Equal([1, 2, 3, 4, 5], b);
    }

    [Fact]
    void Array()
    {
        ReadOnlySpan<int> a = [1, 2, 3];
        int[] b = [.. a, 4, 5];
        Assert.Equal([1, 2, 3, 4, 5], b.AsSpan());
    }

    [Fact]
    void List()
    {
        ReadOnlySpan<int> a = [1, 2, 3];
        List<int> b = [.. a, 4, 5];
        Assert.Equal([1, 2, 3, 4, 5], b);
    }

    [Fact]
    void IEnumerable()
    {
        ReadOnlySpan<int> a = [1, 2, 3];
        IEnumerable<int> b = [.. a, 4, 5];
        Assert.Equal([1, 2, 3, 4, 5], b);

        Assert.Equal("<>z__ReadOnlyArray`1", b.GetType().Name);
    }

    [Fact]
    void ImmutableArray()
    {
        ReadOnlySpan<int> a = [1, 2, 3];
        System.Collections.Immutable.ImmutableArray<int> b = [.. a, 4, 5];

        // Assert.Equal([1, 2, 3, 4, 5], b); // このテストはエラーになる
        Assert.True(b.SequenceEqual([1, 2, 3, 4, 5]));
    }

    [Fact]
    void List_ReadOnlySpan()
    {
        List<int> a = [1, 2, 3];
        ReadOnlySpan<int> b = [.. a, 4, 5];
        Assert.Equal([1, 2, 3, 4, 5], b);
    }

    [Fact]
    void List_IEnumerable()
    {
        List<int> a = [1, 2, 3];
        IEnumerable<int> b = [4, 5];
        IEnumerable<int> c = [.. a, .. b, 6, 7];

        Assert.Equal([1, 2, 3, 4, 5, 6, 7], c);
    }

    static void OldAddRange<T>(List<T> list, IEnumerable<T> items)
    {
        if (items is ICollection<T> collections)
        {
            list.Capacity += collections.Count;
            var array = new T[collections.Count];
            collections.CopyTo(array, 0);
            foreach (var n in array)
            {
                list.Add(n);
            }
        }
        else
        {
            foreach (var n in items)
            {
                list.Add(n);
            }
        }
    }

    [Fact]
    void OldAddRangeTest()
    {
        List<int> list = [1, 2, 3];
        OldAddRange(list, [4, 5]);

        Assert.Equal([1, 2, 3, 4, 5], list);
    }

    static void SpreadVSList(Performance p)
    {
        p.AddTest("Spread", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                List<int> a = [1, 2, 3];
                List<int> b = [.. a, 4, 5];
            }
        });

        p.AddTest("List", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                List<int> a = [1, 2, 3];
                List<int> b = new List<int>(a.Count + 2);
                b.AddRange(a);
                b.Add(4);
                b.Add(5);
            }
        });
    }

    static void ToList(Performance p)
    {
        p.AddTest("ListToList", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                List<int> a = [1, 2, 3];
                List<int> b = [.. a, 4, 5];
            }
        });

        p.AddTest("ReadOnlySpanToList", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                ReadOnlySpan<int> a = [1, 2, 3];
                List<int> b = [.. a, 4, 5];
            }
        });

        p.AddTest("SpanToList", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                Span<int> a = [1, 2, 3];
                List<int> b = [.. a, 4, 5];
            }
        });

        p.AddTest("ArrayToList", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                int[] a = [1, 2, 3];
                List<int> b = [.. a, 4, 5];
            }
        });
    }

    static void ToReadOnlySpan(Performance p)
    {
        p.AddTest("ListToReadOnlySpan", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                List<int> a = [1, 2, 3];
                ReadOnlySpan<int> b = [.. a, 4, 5];
            }
        });

        p.AddTest("ReadOnlySpanToReadOnlySpan", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                ReadOnlySpan<int> a = [1, 2, 3];
                ReadOnlySpan<int> b = [.. a, 4, 5];
            }
        });

        p.AddTest("SpanToReadOnlySpan", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                Span<int> a = [1, 2, 3];
                ReadOnlySpan<int> b = [.. a, 4, 5];
            }
        });

        p.AddTest("ArrayToReadOnlySpan", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                int[] a = [1, 2, 3];
                ReadOnlySpan<int> b = [.. a, 4, 5];
            }
        });
    }

    static void IEnumerableVSArray(Performance p)
    {
        p.AddTest("IEnumerable", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                IEnumerable<int> a = [1, 2, 3];
                IEnumerable<int> b = [.. a, 4, 5];
                var sum = 0;
                foreach (var m in b)
                    sum += m;
            }
        });

        p.AddTest("Array", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                int[] a = [1, 2, 3];
                IEnumerable<int> aEnumerable = a;
                int[] b = [.. aEnumerable, 4, 5];
                IEnumerable<int> bEnumerable = b;
                var sum = 0;
                foreach (var m in bEnumerable)
                    sum += m;
            }
        });
    }

    static void ListAddRange(Performance p)
    {
        p.AddTest("List.AddRange", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                List<int> a = [1, 2, 3];
                a.AddRange([4, 5]);
            }
        });

        p.AddTest("CollectionExtensions.AddRange", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                List<int> a = [1, 2, 3];
                ReadOnlySpan<int> b = [4, 5];
                a.AddRange(b);
            }
        });

        p.AddTest("OldAddRange", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                List<int> a = [1, 2, 3];
                OldAddRange(a, [4, 5]);
            }
        });
    }
}
0
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?