List<T>の列挙方法による速度比較

追記:以下ではStopwatchで実行時間を計測しておりあまり適切ではありませんが、BenchmarkDotNetで再計測したところおおむね同じ結果となり、傾向としては間違っていないと考え、書き直しはしていません。


List<T>に対してforeachのようなことをするには、いくつか方法があります。遊んでいたら思ったより速度差がでて面白かったのでまとめておきます。

サンプルとして、0~100000000までの連続した数のうち偶数の総和を求めるプログラムです。

public class Program
{
    private static List<int> list = new List<int>(Enumerable.Range(0, 100000000));
    private static long sum = 0;

    public static void Main(string[] args)
    {
        UseForeachList();
        UseForeachIEnumerable();
        UseListForEach();
        UseEnumerator();
        UseIEnumerator();
    }

    private static void UseForeachList()
    {
        sum = 0;
        var stopwatch = Stopwatch.StartNew();

        // List<T>をforeachに突っ込むと
        // 最適化されてIEnumerator<T>ではなくEnumeratorが使われているっぽい(結果から推測)
        // インターフェース呼び出しのオーバーヘッドは無い様子
        foreach (var item in list)
        {
            if (item % 2 == 0) { Accumulate(item); }
        }

        stopwatch.Stop();
        Console.WriteLine("foreach List<T> " + stopwatch.ElapsedMilliseconds + "[ms]");
    }

    private static void UseForeachIEnumerable()
    {
        sum = 0;
        var stopwatch = Stopwatch.StartNew();

        // IEnumerable<T>をforeachに突っ込むと
        // IEnumerator<T>が使われるのでインターフェース呼び出しのオーバーヘッドがある
        foreach (var item in list.Where((item) => item % 2 == 0))
        {
            Accumulate(item);
        }

        stopwatch.Stop();
        Console.WriteLine("foreach IEnumerable<T> " + stopwatch.ElapsedMilliseconds + "[ms]");
    }

    private static void UseListForEach()
    {
        sum = 0;
        var stopwatch = Stopwatch.StartNew();

        // List<T>.ForEach()はイテレータではなくforで実装されている
        // インターフェース呼び出しは無いのでオーバーヘッドは無い
        list.ForEach((item) =>
        {
            if (item % 2 == 0) { Accumulate(item); }
        });

        stopwatch.Stop();
        Console.WriteLine("List<T>.ForEach() " + stopwatch.ElapsedMilliseconds + "[ms]");
    }

    private static void UseEnumerator()
    {
        sum = 0;
        var stopwatch = Stopwatch.StartNew();

        // 自分でイテレータを操作する
        // List<T>.GetEnumerator()はEnumerator構造体を返すのでインターフェース呼び出しのオーバーヘッドは無い
        var iterator = list.GetEnumerator();
        while (iterator.MoveNext())
        {
            if (iterator.Current % 2 == 0) { Accumulate(iterator.Current); }
        }

        stopwatch.Stop();
        Console.WriteLine("List<T>.Enumerator " + stopwatch.ElapsedMilliseconds + "[ms]");
    }

    private static void UseIEnumerator()
    {
        sum = 0;
        var stopwatch = Stopwatch.StartNew();

        // 自分でイテレータを操作する
        // Enumerator構造体はIEnumerator<T>を実装しているが
        // IEnumerator<T>を使うとインターフェース呼び出しのオーバーヘッドがある
        IEnumerator<int> iterator = list.GetEnumerator();
        while (iterator.MoveNext())
        {
            if (iterator.Current % 2 == 0) { Accumulate(iterator.Current); }
        }

        stopwatch.Stop();
        Console.WriteLine("IEnumerator<T> " + stopwatch.ElapsedMilliseconds + "[ms]");
    }

    private static void Accumulate(int value)
    {
        sum += value;
    }
}

リリースビルドでの実行結果(デバッグビルドだと最適化が行われないので結果が変わります)

foreach List<T> 246[ms]
foreach IEnumerable<T> 571[ms]
List<T>.ForEach() 294[ms]
List<T>.Enumerator 242[ms]
IEnumerator<T> 728[ms]

処理内容によるでしょうが、今回の場合インターフェース呼び出しすると2倍以上の時間がかかるようでした。

面白いのが、ForEach()を使えばforeachより速いor最適化により同程度だろうと予想していたのに、foreachの方が速かったこと。ForEach()使うと見た目がスッキリするだけでむしろ若干遅くなるなんて。

foreach IEnumerable<T>よりも自分でイテレータを操作した方が結構遅いのは、foreachだと何か最適化が入るのかなーと思います。

結論としては、通常は何も考えずforeachつかっておけばよく、もしインターフェース呼び出しによるオーバーヘッドが問題になる場合はGetEnumerator()で構造体のイテレータをもらって自分で操作するとより速い可能性があるということでした。