はじめに
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]);
}
});
}
}