Почему foreach работает для массива намного быстрее, чем для IEnumerable?

Недавно рефаторил проект, делал универсальные методы, абстракции в аргументах вместо конкретных типов, и заметил, что производительность приложения незначительно упала. Начал копать, добрался до того, что виноват во всём цикл foreach.

Кажется, foreach оптимизирован для массива. Решил протестировать.

Вот 2 совершенно одинаковых метода, исходные данные - один и тот же массив. Разница только в сигнатуре.

class Program
{
    static void Main(string[] args)
    {
        var result = BenchmarkRunner.Run<ForeachBenchmarks>();
        Console.ReadKey();
    }
}

[MemoryDiagnoser]
public class ForeachBenchmarks
{
    private readonly int[] _numbers = Enumerable.Repeat(1, 1000000).ToArray();
    public IEnumerable<int[]> Numbers { get { yield return _numbers; } }

    [Benchmark]
    [ArgumentsSource(nameof(Numbers))]
    public int SumArray(int[] numbers)
    {
        int sum = 0;
        foreach (int n in numbers)
            sum += n;
        return sum;
    }

    [Benchmark]
    [ArgumentsSource(nameof(Numbers))]
    public int SumIEnumerable(IEnumerable<int> numbers)
    {
        int sum = 0;
        foreach (int n in numbers)
            sum += n;
        return sum;
    }
}

И правда оптимизирован:

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19043.1081 (21H1/May2021Update)
Intel Core i7-4700HQ CPU 2.40GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
.NET SDK=5.0.301
  [Host]     : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT
  DefaultJob : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT
Method numbers Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
SumArray Int32[1000000] 468.9 us 1.84 us 1.44 us - - - -
SumIEnumerable Int32[1000000] 5,808.0 us 44.16 us 39.15 us - - - 32 B

Объясните пожалуйста, почему foreach в 10 раз медленнее, если использовать интерфейс IEnumerable<T> для массива, чем если использовать T[]?

UPD: Провел тесты для List<int> и для ReadOnlySpan<int>. Для списка foreach такой же по производительности, как для IEnumerable<T>, а для спана такой же как для массива.


Ответы (1 шт):

Автор решения: Андрей NOP

Потому что когда явно известно что на входе массив, компилятор может позволить себе оптимизацию и сгенерировать код, который просто будет обращаться по индексу, что очень быстро. В случае же с IEnumerable приходится действовать как положено — с созданием итератора, вызовом его методов и т.д., что дает дополнительную константу в O(n), которую ваш тест и показал.

Для сравнения, код на выходе примерно соответствует такому:

public int SumArray(int[] numbers)
{
    int num = 0;
    int num2 = 0;
    while (num2 < numbers.Length)
    {
        int num3 = numbers[num2];
        num += num3;
        num2++;
    }
    return num;
}

public int SumIEnumerable(IEnumerable<int> numbers)
{
    int num = 0;
    IEnumerator<int> enumerator = numbers.GetEnumerator();
    try
    {
        while (enumerator.MoveNext())
        {
            int current = enumerator.Current;
            num += current;
        }
    }
    finally
    {
        if (enumerator != null)
        {
            enumerator.Dispose();
        }
    }
    return num;
}

Подсмотрено здесь

→ Ссылка