Уважаемые читатели, в этой статье я хочу рассказать о скрытых аллокациях в обычном foreach и представить свои выводы. Тесты сделаны на .net 8, .net 9 и .net 10 одновременно, чтобы было видно, как одна и та же строчка кода ведет себя на разных рантаймах.
Все тесты сделаны с использованием BenchmarkDotNet, полный код представлен на гите, так что каждый может проверить результаты и сделать свои выводы.
Будет три истории, и все три про foreach:
-
SortedList из стандартной библиотеки аллоцирует память на каждый foreach, а Dictionary — нет. Из-за одной строчки в сигнатуре метода. И починить это по-нормальному нельзя.
-
.NET 10 научился убирать такие аллокации вообще без изменения вашего кода. И по пути выяснилось, что одна старая оптимизация мешала новой.
-
У новой оптимизации есть задокументированная самим Microsoft граница — на больших коллекциях она может срываться. Я пошел ловить этот обрыв с бенчмарком — и получилось интереснее, чем я ожидал.
Каждое утверждение подкреплено ссылкой на 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; } }
Результаты:
Смотрим на .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 на всех четырех:
Картина одинаковая везде: 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):
То есть в .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; } }
Результаты:
Тут все сбылось буквально. На .net 8 и .net 9 массив через интерфейс — это 32 байта боксинга на каждый вызов и просадка в 4.4–5.2 раза по времени. На .net 10 — прочерк в Allocated, а время 152.9 ns: интерфейсный цикл стал всего на 17% медленнее прямого — при том же самом коде.
Это и есть деабстракция в действии, и это тот самый ответ, почему нельзя верить статьям с выводами уровня «foreach по IEnumerable всегда боксит»: на .net 8 — да, на .net 10 — уже нет. Сводка по всем машинам (Array_AsIEnumerable):
Отдельно повеселила машина №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, в итоге сам был съеден оптимизатором вместе с циклом.
Смотрим машинный код
Колонке 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; } }
Результаты (для компактности сгруппировал по размеру):
Сначала подтвержденная часть. На .net 8 и .net 9 картина железная: 40 байтов на боксинг энумератора на любом размере и трехкратная просадка по времени. На .net 10 на маленьких размерах — нули и время вплотную к прямому перебору (ratio 1.02 на тысяче элементов — интерфейс стал практически бесплатным).
Теперь главное — миллион элементов. Обрыв… не случился. Прочерк в Allocated, ratio 1.01. Я ждал возврата 40 байтов, перечитал пост Тауба, перепроверил цифры — обрыва нет. Признаюсь, такого я не ожидал. Первая мысль была: может, дело в конкретной сборке рантайма? Проверяем на всех машинах, List_AsIEnumerable на миллионе:
Нет. Три разные сервисные сборки .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, из которых взяты фрагменты в статье
Ссылки
-
Issue №81128 — SortedList Enumerator is allocated on the heap
-
Issue №108913 — JIT: De-abstraction in .NET 10 (план деабстракции, синглтон пустого энумератора)
-
Issue №104936 — Stack Allocation Enhancements (трекинг escape analysis)
-
Stephen Toub — Performance Improvements in .NET 10 (разделы про deabstraction, OSR и PGO)
-
Protobuf issue №13503 — та же дилемма со сменой сигнатуры GetEnumerator
-
Issue №4505, открыт в 2015 — этой проблеме официально больше десяти лет
Всем удачи и до новых встреч!
PS: все таблицы в статье — реальные прогоны на четырех машинах из таблицы в начале. На вашем железе абсолютные цифры будут свои — важны не наносекунды, а нули против ненулей в Allocated. Эвристики JIT меняются между сервисными релизами, так что если у вас картина другая (особенно если поймаете обрыв из третьей истории или найдете причину регрессии .net 9 из первой) — пишите в комментариях с версией рантайма, это самое интересное.
ссылка на оригинал статьи https://habr.com/ru/articles/1058160/