Бенчмаркая foreach и энумераторы: аллокации, которые прятались 10 лет

от автора

Уважаемые читатели, в этой статье я хочу рассказать о скрытых аллокациях в обычном foreach и представить свои выводы. Тесты сделаны на .net 8, .net 9 и .net 10 одновременно, чтобы было видно, как одна и та же строчка кода ведет себя на разных рантаймах.

Все тесты сделаны с использованием BenchmarkDotNet, полный код представлен на гите, так что каждый может проверить результаты и сделать свои выводы.

Будет три истории, и все три про foreach:

  • SortedList из стандартной библиотеки аллоцирует память на каждый foreach, а Dictionary — нет. Из-за одной строчки в сигнатуре метода. И починить это по-нормальному нельзя.

  • .NET 10 научился убирать такие аллокации вообще без изменения вашего кода. И по пути выяснилось, что одна старая оптимизация мешала новой.

  • У новой оптимизации есть задокументированная самим Microsoft граница — на больших коллекциях она может срываться. Я пошел ловить этот обрыв с бенчмарком — и получилось интереснее, чем я ожидал.

Рис. 1. Аллокации на один foreach: .NET 8/9 против .NET 10 (одинаково на всех четырех машинах)

Рис. 1. Аллокации на один foreach: .NET 8/9 против .NET 10 (одинаково на всех четырех машинах)

Каждое утверждение подкреплено ссылкой на GitHub dotnet/runtime или на официальный блог .NET — специально собрал пруфы, чтобы можно было пойти и проверить первоисточник, а не верить мне на слово. Все ссылки продублированы списком в конце.

Тесты гонялись на четырех машинах — от игровых десктопов до двухпроцессорных серверов:

Машина

CPU

ОС

.NET 10 / 9 / 8

№1

AMD Ryzen 9 5950X, 16 ядер

Windows 10

10.0.5 / 9.0.15 / 8.0.14

№2

Intel Core i9-10900KF, 10 ядер

Windows 10

10.0.9 / 9.0.17 / 8.0.28

№3

2 x Intel Xeon Silver 4314, 32 ядра

Windows Server 2022

10.0.1 / 9.0.5 / 8.0.16

№4

2 x Intel Xeon Silver 4314, 32 ядра

Windows Server 2022

10.0.1 / 9.0.5 / 8.0.16

BenchmarkDotNet везде v0.15.8. Подробные таблицы по ходу статьи — с машины №2, по остальным привожу сводные сравнения. Обратите внимание на версии .NET 10: 10.0.1, 10.0.5 и 10.0.9 — три разные сервисные сборки, это еще сыграет роль в третьей истории.

Немного теории

Про foreach написано немало, поэтому сильно углубляться не буду, но немного напишу, что да как — иначе дальше будет непонятно, откуда берутся аллокации там, где нет ни одного new.

Энумератор — это маленький объект-курсор, который умеет две вещи: сдвинуться на следующий элемент (MoveNext()) и отдать текущий (Current). Когда вы пишете foreach, компилятор за кулисами берет у коллекции энумератор и крутит его в цикле while. То есть каждый foreach — это создание одного энумератора.

Дальше главный вопрос: где живет этот энумератор — на стеке (бесплатно, сборщик мусора про него даже не узнает) или на куче (аллокация, которую потом придется собирать GC)?

Тут есть неочевидный факт: foreach работает не через интерфейс, а через утиную типизацию — по классике: если это выглядит как утка, плавает как утка и крякает как утка — значит, это утка. Компилятору не нужен IEnumerable — ему нужен просто метод с именем GetEnumerator(). И компилятор берет тот метод, который видит по типу переменной. Поэтому у List<T> внутри целых три метода GetEnumerator:

// Так выглядит List<T> внутри (упрощенно): // 1. Публичный. Возвращает struct КАК ЕСТЬ. Его берет foreach,//    когда тип переменной - List<T>. Ноль аллокаций.public List<T>.Enumerator GetEnumerator()    => new List<T>.Enumerator(this); // 2. Явная реализация интерфейса. Возвращает ТОТ ЖЕ struct,//    но уже как интерфейс. Struct в интерфейсной переменной жить//    не может - происходит боксинг в объект на куче.IEnumerator<T> IEnumerable<T>.GetEnumerator()    => new List<T>.Enumerator(this); // 3. То же самое для старого необобщенного IEnumerable.IEnumerator IEnumerable.GetEnumerator()    => new List<T>.Enumerator(this);

Боксинг — это когда struct нужно передать туда, где ждут ссылку на объект. Рантайм выделяет объект на куче и копирует туда значение структуры. Каждый боксинг — это аллокация, и ее видно в колонке Allocated у BenchmarkDotNet.

А теперь пример, вокруг которого крутится вся статья. Код одинаковый, разница только в типе переменной:

var list = new List<int> { 1, 2, 3 }; // Тип переменной - List<int>. foreach берет метод №1.// Struct-энумератор живет на стеке. Аллокаций: 0.foreach (var n in list) { } // Тип переменной - IEnumerable<int>. foreach берет метод №2.// Тот же struct, но боксированный на кучу.// Аллокаций: 1 объект на каждый foreach.IEnumerable<int> seq = list;foreach (var n in seq) { }

Вот и вся теория. Никакого new в коде нет, а аллокация есть. Дальше — три истории, где эта мелочь оборачивается интересными последствиями, и бенчмарки, чтобы все пощупать руками.

История первая: SortedList, который аллоцирует, и никто не может это починить

В январе 2023 года разработчик под ником emilsteen завел в dotnet/runtime issue №81128. История максимально жизненная: у человека на проде foreach по маленькому SortedList на 15 элементов, он гоняет его миллион раз и видит в BenchmarkDotNet аллокации. А точно такой же foreach по Dictionary — ноль байт. Он не поленился и раскопал причину сам.

Причина — одна строчка. Сравните сигнатуры публичных методов GetEnumerator у двух коллекций:

// Dictionary<TKey, TValue> - возвращает конкретный struct:public Dictionary<TKey, TValue>.Enumerator GetEnumerator() // SortedList<TKey, TValue> - возвращает интерфейс:public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()

Внутри SortedList энумератор тоже структура (когда-то, еще после .NET Framework, его переделали из класса в структуру именно ради экономии). Но публичный метод отдает его через интерфейс — и структура боксится прямо на выходе. foreach по типу переменной SortedList находит именно этот метод. Итог: каждый foreach по SortedList — гарантированный боксинг, и никакая структура внутри не спасает.

Автор issue проверил и это: если поменять возвращаемый тип на конкретный Enumerator — аллокация уходит, а сам GetEnumerator по его замерам становится минимум вдвое быстрее. Он же нашел ту же болячку в ReadOnlyDictionary и еще нескольких коллекциях.

Кстати, на Хабре этот факт уже всплывал — но именно как загадка. В комментариях к статье про Dictionary и SortedDictionary (habr.com/ru/articles/784852/comments) юзер vvdev, гоняя свои бенчмарки, заметил: SortedList выделяет память в куче при каждом вызове GetEnumerator, константные 48 байтов — и честно добавил, что не смотрел, что именно там выделяется.

Вот эти 48 байтов — это и есть боксированный энумератор: заголовок объекта плюс поля структуры (точный размер зависит от типов ключа и значения). То есть на эти 48 байтов уже натыкались, но что это и откуда — так и осталось без ответа. Сейчас разберемся.

Почему Microsoft просто не поменяет одну строчку?

Вот тут самое интересное. Отвечал emilsteen в треде сам Стивен Тауб из команды .NET. Почему энумератор аллоцируется, он объяснил одним словом: Boxing. А на предложение поменять возвращаемый тип ответил: это ломающее изменение (breaking change) — возвращаемый тип входит в сигнатуру метода, и весь уже скомпилированный чужой код, который делает foreach по SortedList, сломается.

Issue закрыли как answered: по API все работает как задумано, просто задумано было неудачно, и теперь это навсегда. Ровно с той же дилеммой, кстати, столкнулись в Google Protobuf (github.com/protocolbuffers/protobuf/issues/13503) — у них коллекции боксят энумераторы по той же причине, и там тоже пришли к выводу: поменять сигнатуру нельзя, можно только добавить рядом новый метод с другим именем.

Мораль первой истории простая: вся разница между «ноль аллокаций» и «аллокация на каждый foreach» — это возвращаемый тип у GetEnumerator. Ошиблись в нем даже авторы стандартной библиотеки, а исправить уже нельзя — сигнатуру публичного метода не поменяешь. Теперь проверим цифрами.

Скрытый текст
[MemoryDiagnoser(false)][SimpleJob(RuntimeMoniker.Net80)][SimpleJob(RuntimeMoniker.Net90)][SimpleJob(RuntimeMoniker.Net10_0, baseline: true)]public class SortedListVsDictionary{    private readonly SortedList<int, int> _sortedList = new();    private readonly Dictionary<int, int> _dictionary = new();    // 15 элементов - как у автора issue #81128, он мерял ровно свой продовый размер    [GlobalSetup]    public void Setup()    {        for (int i = 0; i < 15; i++)        {            _sortedList.Add(i, i);            _dictionary.Add(i, i);        }    }    [Benchmark(Baseline = true)]    public int Dictionary_Foreach()    {        int sum = 0;        foreach (KeyValuePair<int, int> pair in _dictionary)        {            sum += pair.Value;        }        return sum;    }    [Benchmark]    public int SortedList_Foreach()    {        int sum = 0;        foreach (KeyValuePair<int, int> pair in _sortedList)        {            sum += pair.Value;        }        return sum;    }    // Контрольный выстрел: тот же Dictionary, но через интерфейс.    // Аллокация возвращается, потому что энумератор уезжает через IEnumerable.    // Это тот же эффект, что у SortedList, только мы его создали сами.    [Benchmark]    public int Dictionary_AsIEnumerable_Foreach()    {        int sum = 0;        IEnumerable<KeyValuePair<int, int>> pairs = _dictionary;        foreach (KeyValuePair<int, int> pair in pairs)        {            sum += pair.Value;        }        return sum;    }  }

Результаты:

Рис. 2. SortedListVsDictionary: результаты на машине №2

Рис. 2. SortedListVsDictionary: результаты на машине №2

Смотрим на .net 8 и .net 9: SortedList_Foreach — 48 байтов на каждый foreach, Dictionary_Foreach — прочерк. И обратите внимание: это ровно те же 48 байтов из хабр-комментария, о котором я писал выше — теперь мы знаем, что это за байты: боксированный struct-энумератор (заголовок объекта плюс поля структуры). Контрольный Dictionary_AsIEnumerable_Foreach тоже показал 48 B — то есть мы руками превратили Dictionary в SortedList одним приведением типа, как и обещала теория.

А теперь .net 10 — и интрига разрешилась: все три строчки по нулям. Escape analysis дотянулся и до SortedList: JIT заинлайнил GetEnumerator, увидел, что боксированный энумератор не покидает метод, и разместил его на стеке. Причем не только память — время: 12.18 ns против 37.14 ns на .net 8, в три раза быстрее без единой измененной строчки кода.

Получается интересная штука: в исходниках SortedList ничего не поменялось — GetEnumerator как возвращал интерфейс, так и возвращает. Проблему, которую нельзя было исправить в коде библиотеки, исправил уровень ниже — рантайм.

Отмечу тот факт, что смотреть тут надо в первую очередь на колонку Allocated, а не на время: вся история — про память и давление на GC, и ноль против ненуля — это диагноз. А теперь проверим, что это не особенность одной машины. SortedList_Foreach на всех четырех:

Рис. 3. SortedList_Foreach на четырех машинах: время / Allocated

Рис. 3. SortedList_Foreach на четырех машинах: время / Allocated

Картина одинаковая везде: 48 байтов на .net 8/9 и ноль на .net 10 — на трех разных сервисных сборках десятки (10.0.1, 10.0.5, 10.0.9) и на разном железе.

Тут я удивился, но не десятке, а девятке. Посмотрите на Dictionary_AsIEnumerable_Foreach: на .net 9 он в 2.4 раза медленнее, чем тот же код на .net 8. Сначала я списал это на кривой прогон, но нет — эффект воспроизвелся на всех четырех машинах и на трех разных версиях .NET 9 (9.0.5, 9.0.15, 9.0.17):

Рис. 4. Dictionary_AsIEnumerable_Foreach на четырех машинах

Рис. 4. Dictionary_AsIEnumerable_Foreach на четырех машинах
Рис. 5. Регрессия .NET 9 на переборе Dictionary через интерфейс

Рис. 5. Регрессия .NET 9 на переборе Dictionary через интерфейс

То есть в .net 9 живет стабильная регрессия на переборе словаря через интерфейс, которую в .net 10 не просто починили, а перепрыгнули с запасом. Причину я не раскапывал — похоже на неудачную эвристику девиртуализации именно в девятке. Если кто-то в комментариях докопается до конкретного изменения — будет интересно.

История вторая: .NET 10 заходит с другой стороны

Итак, чинить сигнатуры в API нельзя. Но в .NET 10 команда рантайма пошла другим путем: раз нельзя убрать боксинг из кода, пусть его убирает JIT-компилятор на лету. Это называется деабстракция (de-abstraction), и это официально одно из главных направлений релиза.

Здесь работают два механизма:

  • Escape analysis (анализ убегания). JIT смотрит на объект и спрашивает: этот объект покидает метод? Сохраняется в поле, возвращается наружу, уходит в неизвестный чужой код? Если нет — значит, время его жизни ограничено методом, и объект можно не выделять на куче, а разместить прямо на стеке. Стек освобождается сам при выходе из метода, GC вообще не в курсе, что объект существовал.

  • PGO и guarded devirtualization. Рантайм какое-то время наблюдает за методом и собирает профиль: какой конкретный тип реально приходит в переменную IEnumerable. Если в 99% случаев это int[] — JIT генерирует отдельную быструю ветку кода именно под int[], где все вызовы энумератора уже не виртуальные, инлайнятся, и энумератор можно стек-аллоцировать. На случай, если придет что-то другое, остается запасная медленная ветка (потому и guarded — с охраной).

Пруфы. Официальный план деабстракции — issue №108913, там прямым текстом: благодаря PR №108153 мы можем девиртуализовать и заинлайнить конструктор энумератора и вызовы на нем, а в ряде случаев даже стек-аллоцировать энумератор — и в таблице бенчмарков .NET 10 аллокаций больше нет.

То же самое — в официальной документации What’s new in .NET 10 runtime и в огромном посте Стивена Тауба Performance Improvements in .NET 10. Тауб отдельно пишет: с прогрессом .NET 10 это происходит очень часто для массивов и List<T>, а интерфейс IEnumerator<T> даже пометили как [Intrinsic], чтобы JIT мог рассуждать о нем напрямую.

Как одна оптимизация мешала другой: пустой массив

А теперь самое интересное из issue №108913 — то, ради чего отчасти и писалась эта статья. Когда команда JIT взялась стек-аллоцировать энумераторы массивов, она уперлась в… собственную старую оптимизацию.

Дело вот в чем. Энумератор массива — это класс SZGenericArrayEnumerator<T>. И в его создании давным-давно сделали экономию: для пустого массива новый энумератор не создается, а возвращается один общий статический экземпляр (синглтон). Логика железная — зачем плодить объекты ради перебора нуля элементов.

Но для escape analysis это подстава: теперь в точке, где крутится цикл, JIT не может сказать, какой именно объект он итерирует — свежесозданный (его можно на стек) или тот самый общий синглтон (его на стек нельзя, он общий на весь процесс). Неоднозначность — и анализ пасует. В issue это описано прямым текстом: из-за оптимизации для пустых массивов в точках использования энумератора возникает неопределенность, какой объект итерирует.

Анти-аллокационный трюк десятилетней давности блокировал анти-аллокационную оптимизацию 2025 года!

Пришлось учить JIT разруливать оба варианта (это и называется conditional escape analysis — стек-аллокация с проверкой условия на входе). По-моему, это лучшая иллюстрация того, почему в перформансе нельзя ничего принимать на веру: даже оптимизации внутри самого рантайма могут конфликтовать друг с другом.

Скрытый текст
[MemoryDiagnoser(false)][SimpleJob(RuntimeMoniker.Net80)][SimpleJob(RuntimeMoniker.Net90)][SimpleJob(RuntimeMoniker.Net10_0, baseline: true)]public class ArrayDeabstraction{    private readonly int[] _array = Enumerable.Range(0, 512).ToArray();    private readonly int[] _empty = [];    // Эталон: foreach по массиву напрямую. Компилятор разворачивает в for по индексу.    // Ноль аллокаций на любом рантайме, интерфейсы не участвуют вообще.    [Benchmark(Baseline = true)]    public int Array_Direct()    {        int sum = 0;        foreach (int i in _array)        {            sum += i;        }        return sum;    }    // Главный подопытный: тот же массив, но через переменную интерфейсного типа.    // Тут foreach обязан получить энумератор через IEnumerable<int>.GetEnumerator(),    // а это объект на куче. Смотрим, на каком рантайме колонка Allocated обнулится.    [Benchmark]    public int Array_AsIEnumerable()    {        int sum = 0;        IEnumerable<int> items = _array;        foreach (int i in items)        {            sum += i;        }        return sum;    }    // Через невиртуальный метод-хелпер, как в примере Стивена Тауба из блога    // "Performance Improvements in .NET 10": PGO видит, что сюда всегда приходит int[],    // делает специализированную ветку и стек-аллоцирует энумератор.    [Benchmark]    public int Array_ViaHelper() => Sum(_array);    [MethodImpl(MethodImplOptions.NoInlining)]    private static int Sum(IEnumerable<int> values)    {        int sum = 0;        foreach (int i in values)        {            sum += i;        }        return sum;    }    // Пустой массив через интерфейс: сюда прилетает синглтон-энумератор.    // Аллокаций нет и не было (синглтон!), но именно этот случай исторически    // ломал escape analysis для НЕпустых массивов - см. issue 108913.    [Benchmark]    public int EmptyArray_AsIEnumerable()    {        int sum = 0;        IEnumerable<int> items = _empty;        foreach (int i in items)        {            sum += i;        }        return sum;    }  }

Результаты:

Рис. 6. ArrayDeabstraction: результаты на машине №2

Рис. 6. ArrayDeabstraction: результаты на машине №2

Тут все сбылось буквально. На .net 8 и .net 9 массив через интерфейс — это 32 байта боксинга на каждый вызов и просадка в 4.4–5.2 раза по времени. На .net 10 — прочерк в Allocated, а время 152.9 ns: интерфейсный цикл стал всего на 17% медленнее прямого — при том же самом коде.

Это и есть деабстракция в действии, и это тот самый ответ, почему нельзя верить статьям с выводами уровня «foreach по IEnumerable всегда боксит»: на .net 8 — да, на .net 10 — уже нет. Сводка по всем машинам (Array_AsIEnumerable):

Рис. 7. Array_AsIEnumerable на четырех машинах: время / Allocated

Рис. 7. Array_AsIEnumerable на четырех машинах: время / Allocated

Отдельно повеселила машина №1: на Ryzen интерфейсный вариант на .net 10 (116.2 ns) обогнал даже прямой foreach по массиву (124.9 ns). Нет, интерфейсы не стали быстрее массивов — после всех оптимизаций JIT генерирует для обоих вариантов практически одинаковый цикл, и кто из двух окажется на пару процентов быстрее, решают уже случайные мелочи вроде того, как код лег в памяти. Но сам факт показателен: раньше интерфейс проигрывал в четыре раза, теперь эти два варианта почти неотличимы.

Попутно два наблюдения по таблице:

  • .net 9 в этом тесте медленнее .net 8 (682 против 582 ns) — и это уже второй случай, где девятка проседает относительно восьмерки. Прогресс между версиями не обязан быть монотонным: эвристики JIT перекраиваются, и отдельные сценарии могут временно деградировать.

  • EmptyArray_AsIEnumerable на .net 10 — 0.0003 ns. Это не опечатка и не сломанный замер: JIT понял, что перебор пустого массива не делает ничего, и выкинул весь цикл целиком — метод схлопнулся до «верни ноль». Синглтон-энумератор, который мешал escape analysis, в итоге сам был съеден оптимизатором вместе с циклом.

Рис. 8. foreach по массиву через IEnumerable: время на вызов

Рис. 8. foreach по массиву через IEnumerable: время на вызов

Смотрим машинный код

Колонке Allocated можно верить, но раз уж мы охотимся на то, чего нет в исходниках, финальный пруф — в машинном коде. JIT умеет показывать сгенерированный код через переменную окружения, без каких-либо инструментов. Чтобы вывод был чистым, я вынес тот же foreach по IEnumerable<int> в отдельную мини-программу (метод SumOverInterface, код в репозитории) и снял листинги на машине №2:

dotnet build -c Releaseset DOTNET_JitDisasm=*SumOverInterface*bin\Release\net8.0\DisasmProof.exe  > disasm_net8.txt  2>&1bin\Release\net10.0\DisasmProof.exe > disasm_net10.txt 2>&1

JIT печатает метод несколько раз — по мере прогрева он перекомпилирует его от простого tier 0 до оптимизированного tier 1. Нас интересуют финальные tier 1-версии. Сначала .net 8 (листинги сокращены до смысловых строк):

; .NET 8, Tier1, optimized using Dynamic PGO   ; всего 460 байт кода        call     [r11]IEnumerable`1[int]:GetEnumerator()       mov      gword ptr [rbp-0x30], rcx   ; ссылка на объект-энумератор В КУЧЕ G_M000_IG06:                                ; тело цикла, каждая итерация:       cmp      rsi, rdi                    ; тип энумератора все еще тот, что угадали?       jne      SHORT G_M000_IG10           ; нет - уходим на виртуальные вызовы       ...G_M000_IG10:       call     [r11]IEnumerator:MoveNext() ; виртуальный вызов (медленный путь)       ...G_M000_IG24:       call     [r11]IDisposable:Dispose()  ; виртуальный Dispose в конце

Что тут происходит. Восьмерка с включенным PGO уже не так проста: она угадала конкретный тип энумератора и заинлайнила его MoveNext и Current (это guarded devirtualization). Но два момента никуда не делись: сам объект энумератора создается в куче внутри невиртуализованного вызова GetEnumerator — это и есть те 32 байта из таблиц (gword в листинге — ссылка на объект в куче, которую отслеживает GC), и проверка типа повторяется на каждой итерации цикла.

Теперь .net 10:

; .NET 10, Tier1                               ; всего 193 байта кода        mov      rax, 0x7FF9F36CB188         ; method table типа int[]       cmp      qword ptr [rcx], rax        ; проверка типа ОДИН раз, на входе       jne      SHORT G_M000_IG07           ; не массив - запасной общий путь       mov      eax, dword ptr [rcx+0x08]   ; длина массива G_M000_IG03:                                ; весь foreach целиком:       add      ebx, dword ptr [rcx+4*r8+0x10]  ; читаем элемент прямо из массива       inc      edx       cmp      edx, eax       jb       SHORT G_M000_IG03

Вот ради этих строчек все и затевалось. GetEnumerator на быстром пути не вызывается вообще. Энумератора нет — ни в куче, ни на стеке: JIT его полностью выкинул и переписал foreach в индексный проход по массиву, тот самый, который for.

Проверка типа выполняется один раз на входе в метод, а не на каждой итерации. Размер кода — 193 байта против 460. Аллокация исчезла не потому, что ее куда-то спрятали: ее физически нет в машинном коде.

История третья: оптимизация, которая исчезает с размером списка

Если бы на этом все закончилось, вывод был бы простой: обновляйтесь на .NET 10 и забудьте про боксинг энумераторов. Но нет. У этой оптимизации есть граница, и она задокументирована самим же Таубом в том же посте про .NET 10. Причем проходит она в неожиданном месте — по размеру коллекции.

Тауб описывает два эффекта:

  • Редкая ветка. Внутри MoveNext у List<T> есть запасной метод MoveNextRare — он вызывается один раз в самом конце перебора, чтобы завершить итерацию. То есть на список из 10 элементов приходится 10 обычных шагов и один вызов MoveNextRare, а на список из миллиона — миллион шагов и все равно один MoveNextRare.

    Дальше вступает профилировка. JIT решает, какие методы заинлайнить, глядя на статистику вызовов: часто вызывается — инлайним, почти не вызывается — не тратим на него место. На коротком списке MoveNextRare срабатывает каждый десятый вызов — для JIT это нормальная рабочая ветка, он ее инлайнит, весь энумератор целиком разбирается по косточкам и ложится на стек.

    На длинном списке тот же MoveNextRare — один вызов на миллион, по статистике им можно пренебречь, и JIT его не инлайнит. А вот теперь ловушка: раз метод не заинлайнен, энумератор нужно передать в него как обычный объект — то есть отдать наружу. Для escape analysis «отдать наружу» значит «объект убегает из метода», и класть его на стек больше нельзя. Стек-аллокация срывается — из-за метода, который вызовется один раз.

  • OSR против PGO. Если метод крутит очень длинный цикл, рантайм не ждет его окончания, а перекомпилирует метод прямо посреди выполнения — это называется OSR (On-Stack Replacement). Проблема: OSR-версии методов не собирают PGO-инструментацию. А без профиля для хвоста метода — того места, где вызывается Dispose энумератора — JIT не может сделать guarded devirtualization, и вся цепочка оптимизаций рассыпается.

Итог по описанию должен быть парадоксальный: один и тот же foreach по IEnumerable<int> на .NET 10 дает 0 B на маленьком списке и снова начинает аллоцировать на большом. Звучит как отличный материал для бенчмарка — берем Params на три размера и идем ловить обрыв.

Скрытый текст
[MemoryDiagnoser(false)][SimpleJob(RuntimeMoniker.Net80)][SimpleJob(RuntimeMoniker.Net90)][SimpleJob(RuntimeMoniker.Net10_0, baseline: true)]public class ListSizeCliff{    [Params(10, 1_000, 1_000_000)]    public int Size { get; set; }    private List<int> _list = null!;    [GlobalSetup]    public void Setup()    {        _list = Enumerable.Range(0, Size).ToList();    }    // Эталон: foreach по List напрямую - struct-энумератор, ноль аллокаций везде.    [Benchmark(Baseline = true)]    public long List_Direct()    {        long sum = 0;        foreach (int i in _list)        {            sum += i;        }        return sum;    }    // Подопытный: тот же список через интерфейс.    // .NET 8/9: 40 B на вызов на любом размере.    // .NET 10: ожидаем 0 B на маленьких размерах и возврат аллокации на большом.    [Benchmark]    public long List_AsIEnumerable()    {        long sum = 0;        IEnumerable<int> items = _list;        foreach (int i in items)        {            sum += i;        }        return sum;    }    // Тот же сценарий, но через отдельный метод, чтобы PGO собирал профиль    // именно по этому колл-сайту (так устроен пример из блога .NET).    [Benchmark]    public long List_ViaHelper() => Sum(_list);    [MethodImpl(MethodImplOptions.NoInlining)]    private static long Sum(IEnumerable<int> values)    {        long sum = 0;        foreach (int i in values)        {            sum += i;        }        return sum;    }  }

Результаты (для компактности сгруппировал по размеру):

Рис. 9. ListSizeCliff: результаты на машине №2, три размера списка

Рис. 9. ListSizeCliff: результаты на машине №2, три размера списка

Сначала подтвержденная часть. На .net 8 и .net 9 картина железная: 40 байтов на боксинг энумератора на любом размере и трехкратная просадка по времени. На .net 10 на маленьких размерах — нули и время вплотную к прямому перебору (ratio 1.02 на тысяче элементов — интерфейс стал практически бесплатным).

Теперь главное — миллион элементов. Обрыв… не случился. Прочерк в Allocated, ratio 1.01. Я ждал возврата 40 байтов, перечитал пост Тауба, перепроверил цифры — обрыва нет. Признаюсь, такого я не ожидал. Первая мысль была: может, дело в конкретной сборке рантайма? Проверяем на всех машинах, List_AsIEnumerable на миллионе:

Рис. 10. List_AsIEnumerable на 1 000 000 элементов, четыре машины

Рис. 10. List_AsIEnumerable на 1 000 000 элементов, четыре машины

Нет. Три разные сервисные сборки .NET 10 — 10.0.1, 10.0.5 и 10.0.9 — и на всех нули даже на миллионе элементов. Версия рантайма как подозреваемый отпадает.

Разгадка, как я ее понимаю, в условиях замера:

  • BenchmarkDotNet перед измерением вызывает метод много раз. Метод успевает полностью перекомпилироваться в tier 1 с собранным профилем — и вся цепочка девиртуализаций отрабатывает независимо от размера списка.

  • Эффекты из поста Тауба бьют по другому сценарию: OSR-компиляция включается, когда метод вызвали один-два раза и внутри крутится огромный цикл — рантайм перекомпилирует его прямо посреди выполнения, и у этой OSR-версии профиля нет. В проде это типичный случай: разовая обработка большого файла, миграция, батч при старте сервиса. Бенчмарк такой сценарий физически не меряет — он всегда про горячий, много раз вызванный код.

  • Пост Тауба писался по превью-сборкам, а релизные эвристики явно дотянули: три разные сервисные версии .NET 10 на четырех машинах дали одинаковые нули. Значит, дело не в конкретной сборке, а в самом сценарии замера.

Мораль третьей истории получилась не та, которую я планировал, но она даже лучше: граница у оптимизации есть и задокументирована первоисточником, но проходит она не по размеру коллекции и не по версии рантайма — а по тому, горячий у вас код или разовый.

Мой бенчмарк на четырех машинах ее не поймал, и это тоже результат: не переносите цифры прогретого бенчмарка на код, который вызывается один раз. Если у вас обрыв воспроизведется — напишите в комментариях, с какой версией и на каком размере: будет полезно всем.

Выводы

  • foreach по конкретному типу (List<T>, массив, Dictionary) — бесплатный. foreach по переменной типа IEnumerable<T> — аллокация энумератора, на .net 8/9 всегда, на .net 10 — как повезет с профилем и размером.

  • Если пишете свою коллекцию — из публичного GetEnumerator возвращайте конкретный struct, интерфейсные версии делайте явной реализацией. Вернете интерфейс — получите свой SortedList с аллокацией на каждый foreach, и исправить это потом будет нельзя.

  • SortedList, ReadOnlyDictionary и часть других коллекций BCL аллоцируют на каждый foreach на .net 8/9. Если такой foreach у вас на горячем пути — обходите по индексу или берите другую коллекцию.

  • На .NET 10 не спешите переписывать код ради боксинга энумераторов — сначала померяйте: возможно, JIT уже все убрал. Но помните, что бенчмарк меряет прогретый код: у разово вызванного метода с огромным циклом (OSR-путь) оптимизация по данным команды .NET может срываться, хотя в моих прогонах на 10.0.9 обрыв не воспроизвелся даже на миллионе элементов.

  • Колонка Allocated в MemoryDiagnoser — первое, на что стоит смотреть. Ноль против ненуля важнее любых наносекунд.

Код из статьи

  • HiddenEnumerators — бенчмарки: три класса, мульти-таргет на net8.0/net9.0/net10.0, сводка и графики после прогона

  • DisasmProof — снятие машинного кода: программа, snap.bat и полные листинги disasm_net8.txt / disasm_net10.txt, из которых взяты фрагменты в статье

Ссылки

Всем удачи и до новых встреч!

PS: все таблицы в статье — реальные прогоны на четырех машинах из таблицы в начале. На вашем железе абсолютные цифры будут свои — важны не наносекунды, а нули против ненулей в Allocated. Эвристики JIT меняются между сервисными релизами, так что если у вас картина другая (особенно если поймаете обрыв из третьей истории или найдете причину регрессии .net 9 из первой) — пишите в комментариях с версией рантайма, это самое интересное.

ссылка на оригинал статьи https://habr.com/ru/articles/1058160/