先日発表された.NET 10 Preview1 ではランタイムを最適化するための3つの機能強化が追加されました。
その一つである「配列インターフェースメソッドの脱仮想化(Array Interface Method Devirtualization)」について調べてみると、色々と面白いことを知ったので紹介します。
間違ってたらごめんなさい。
何が改善されたのか
.NET 10 では、JIT(Just-In-Time コンパイラ)が配列をインターフェース経由で操作する際の最適化を強化しました。これにより、foreach
を使ってIEnumerable<T>
として配列をループする場合でも、従来のfor
ループと同等のパフォーマンスが期待できるようになりました。
簡単に言うと、インターフェースを介したforeach
のパフォーマンスが大幅に向上しました。
背景
インターフェースを介したforeach
のパフォーマンスが良くなかった要因としては、主に以下の二つが挙げられます。
- 境界チェック(Bounds Checking)
- 仮想呼び出し(Virtual Call)
1.境界チェック(Bounds Checking)
C#では、配列の要素にアクセスするたびに境界チェック(Bounds Checking)が行われます。これは、配列のインデックスが範囲外でないかを確認する仕組みです。
int[] array = { 1, 2, 3 };
int value = array[5]; // 範囲外のアクセス → IndexOutOfRangeException
JITは、for
ループのようにインデックスが範囲内であることが明確な場合、不要な境界チェックを削除してパフォーマンスを向上させます。
public int Sum(int[] array)
{
int sum = 0;
for (int i = 0; i < array.Length; i++) // iが0からarray.Length-1までの範囲に収まることが明確
{
sum += array[i]; // JITによる境界チェックの省略
}
return sum;
}
しかし、foreach
ループを使いIEnumerable<T>
として配列を扱うと、インデックスが範囲内であることを毎回確認する必要が生じるため、この最適化が難しくなります。
2.仮想呼び出し(Virtual Call)
仮想呼び出し(Virtual Call)とは、メソッドの呼び出し先が実行時に決定されることです。C#のinterface
やvirtual
メソッドを使うと、どのメソッドが実行されるかJITは事前に確定できません。
interface IAnimal
{
void Speak();
}
class Dog : IAnimal
{
public void Speak() => Console.WriteLine("Woof!");
}
class Cat : IAnimal
{
public void Speak() => Console.WriteLine("Meow!");
}
static void MakeAnimalSpeak(IAnimal animal)
{
animal.Speak(); // 仮想呼び出し
}
MakeAnimalSpeak(new Dog());
を実行すると、JITはDogかCatどちらのSpeak()
を呼ぶのかを事前には特定できないため、仮想テーブル(vtable)を参照し、実行時にメソッドを決定します。この追加処理がパフォーマンスの低下を招きます。
foreach
の最適化が難しかった理由
以上の内容を踏まえると、foreach
の最適化が難しかった理由が見えてきます。
foreach
ループを使用すると、コンパイラは内部で以下のような処理を実行します。
-
GetEnumerator()
メソッドを呼び出して、イテレーター(`IEnumerator')を取得する -
MoveNext()
メソッドを毎回呼び出して、次の要素に進む -
Current
プロパティを取得して、現在の値を参照する
つまり、foreach
をIEnumerable<T>
として扱うと、仮想呼び出しが発生するためJITはメソッドのインライン化を適用できず、結果としてループ展開や境界チェック削除などの最適化が難しくなるのです。
public int Sum(int[] array)
{
int sum = 0;
IEnumerable<int> temp = array;
foreach (var num in temp) // 仮想呼び出しが発生
{
sum += num;
}
return sum;
}
どう変わる?
.NET 10 では、JITがIEnumerable<T>
をT[]
だと事前に特定できる場合は仮想呼び出しを回避し、直接T[]
のメソッドを呼ぶよう最適化されるようになります。
事前に特定できる場合って?
- 代入時に
T[]
であることが明確な場合
static int Sum(int[] array)
{
int sum = 0;
IEnumerable<int> temp = array; // `temp`に`int[]`を直接代入
foreach (var num in temp) // .NET 10 では脱仮想化される
{
sum += num;
}
return sum;
}
- foreachの対象が
T[]
であるとJITが確信できる場合
static void Print(IEnumerable<int> collection)
{
foreach (var num in collection)
{
Console.WriteLine(num);
}
}
static void Main()
{
int[] numbers = { 1, 2, 3 };
Print(numbers); // `Print(IEnumerable<int>)` に `int[]` を直接渡す
}
-
foreach
のループ対象がローカル変数でT[]
に型推論される場合
static int Sum(IEnumerable<int> collection)
{
int sum = 0;
foreach (var num in collection)
{
sum += num;
}
return sum;
}
static void Main()
{
int[] numbers = { 1, 2, 3 };
Console.WriteLine(Sum(numbers)); // `numbers` は `int[]`
}
などなど
まとめ
.NET 10 のJIT最適化により、インターフェースを経由したforeach
ループのパフォーマンスが大幅に向上しました。これにより、可読性の高いforeach
を使用しても、for
ループと同等のパフォーマンスを期待できるようになります。
個人的な感想としては、そもそもインターフェースを経由したforeach
ループのパフォーマンスが比較的悪かったことを全く知らなかったので、面白い発見でした。