9

Недавно рефаторил проект, делал универсальные методы, абстракции в аргументах вместо конкретных типов, и заметил, что производительность приложения незначительно упала. Начал копать, добрался до того, что виноват во всём цикл 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&lt;int&gt; 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>, а для спана такой же как для массива.

aepot
  • 49,560
  • 1
    Потому что больше кода генерируется? Дополнительные объекты, вызовы методов. А если явно известно что на входе массив, то используется оптимизированная версия с обращением по индексу https://sharplab.io/#v2:CYLg1APgAgDABFAjAFgNwFgBQWoGYEBMcAwnAN5ZZzUL4CWAdgC5wDKArgLYCCATrwEMAngApGTANoBdOAy4AjAKa8AzgEoqNCphq644uCq5wAvHBgYde6gDMA9r0UCAxgAs4Y5rP0NZC5eqa1rpGnHBgZgyWwQgA7IZc0TQAvpRW1Hg+LBycSLgieQA84gB8fpxKqhrp5EF6BqGm5knW9o4u7p4svozllYE1MY0Rsi16UPGhY6mYyUA – Андрей NOP Jul 17 '21 at 21:36
  • @АндрейNOP Вон оно что. Черт, а посмотреть в рефлектор я и не догадался. Это всё объясняет, буду лепить перегрузки методов с дублированием кода теперь, чтобы там, где массив, генерилось меньше кода. :) Оформите ответом? Вдруг кому-то пригодится. – aepot Jul 17 '21 at 21:40
  • 2
    Он как-то, получается, оптимизирован для массивов чтоли, или мне кажется? - именно так, если foreach применяется к массиву, msil соответствует обычному for – Grundy Jul 17 '21 at 21:41
  • @Grundy Я не нашел ответа на свой вопрос "Почему foreach работает для массива намного быстрее, чем для IEnumerable?". Не считаю дубликатом, переоткрыл. Ссылка для истории, связанный вопрос: Проход по массиву: прямой, обратный или итератор? – aepot Jul 17 '21 at 22:18
  • @aepot, в ответе прямым текстом говорится, что для массива код foreach такой же, как и for - плюс приведены листинги msil и asm – Grundy Jul 17 '21 at 22:21
  • 2
    @Grundy зато там ничего не сказано про foreach для IEnumerable. Там больше разбор foreach в сравнении с циклом for, а не с самим собой для разных типов коллекций. – aepot Jul 17 '21 at 22:24
  • В дотнете ещё столько возможностей для оптимизации. Печально вздыхает. Вот, например, в IEnumerable.Count проверяется тип и в случае ICollection сразу берётся значение свойства. Почему подобное не сделали для форыча? Открыли бы Мелкомягкие доступ к изменению компилятора, энтузиасты давно бы сделали такие оптимизации. – Alexander Petrov Jul 18 '21 at 15:26
  • 1
    @AlexanderPetrov я думаю, что в при компиляции неизвестно какой тип у коллекции, поэтому генерится с итератором. С другой стороны могли бы проверку типа коллекции вынести в рантайм. Кода бы стало больше конечно, но мне кажется работало бы быстрее. Да и логично это. Я думаю для листа не оптимизировали только потому что не знают как бросить Collection was modified при работе через индексаторы. – aepot Jul 18 '21 at 15:37
  • 1
    @AlexanderPetrov, Открыли бы Мелкомягкие доступ к изменению компилятора, энтузиасты давно бы сделали такие оптимизации — открыто же. Но нельзя просто так завязаться, например, на IList, потому что внутри может быть что угодно. '@aepot, при явном указании IList оптимизации все равно нет. Контракт foreach нельзя нарушать, я могу написать реализацию IList, которая будет бросать исключение при обращении к индексатору, но при этом нормально работать с GetEnumerator().MoveNext() и foreach должен работать! – Андрей NOP Jul 20 '21 at 11:09
  • @AlexanderPetrov: https://habr.com/p/493586/ – Андрей NOP Jul 20 '21 at 11:15
  • @АндрейNOP - открыты исходники, но это не значит, что я могу изменить компилятор. Я не могу в своём проекте вмешаться в ход компиляции. Невозможно изменить код, генерируемый компилятором, невозможно создать собственную языковую конструкцию. У нас нет макросов (хотя в Microsoft работает специалисты, прекрасно знающие их реализацию). Дотнет сильно отстаёт по оптимизациям от JVM и именно поэтому часто сливает Жаве. Хоть обнадёживает, что в последнее время ими серьёзно занялись. – Alexander Petrov Jul 20 '21 at 11:33
  • @Alexander, ну вы всегда можете предпочесть жаву дотнету – Андрей NOP Jul 20 '21 at 18:13

1 Answers1

9

Потому что когда явно известно что на входе массив, компилятор может позволить себе оптимизацию и сгенерировать код, который просто будет обращаться по индексу, что очень быстро. В случае же с 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; }

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