Уважаемые читатели, в этой статье я хочу рассказать о скрытых аллокациях в обычном 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 Release set DOTNET_JitDisasm=*SumOverInterface* bin\Release\net8.0\DisasmProof.exe > disasm_net8.txt 2>&1 bin\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 из первой) — пишите в комментариях с версией рантайма, это самое интересное.
Комментарии (12)

alex3696
12.07.2026 07:16было интересно

alex3696
12.07.2026 07:16А ещё было бы хорошо добавить в сравнение AOT. Для некоторых платформ это единственный вариант. Возможно, я что-то делаю не так, но вот с AOT всё не так радужно и красиво как c прогретым JIT. Мало того - первый или единственный запуск будет скорее всего именно таким.
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8655/25H2/2025Update/HudsonValley2) 12th Gen Intel Core i7-12700 2.10GHz, 1 CPU, 20 logical and 12 physical cores .NET SDK 10.0.301 [Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3 .NET 10.0 : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3 NativeAOT 10.0 : .NET 10.0.9, X64 NativeAOT x86-64-v3 | Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio | |--------------------------------- |--------------- |----------:|------:|----------:|------------:| | Dictionary_Foreach | .NET 10.0 | 7.576 ns | 1.00 | - | NA | | SortedList_Foreach | .NET 10.0 | 9.573 ns | 1.26 | - | NA | | Dictionary_AsIEnumerable_Foreach | .NET 10.0 | 9.167 ns | 1.21 | - | NA | | Dictionary_Foreach | NativeAOT 10.0 | 28.415 ns | 3.75 | - | NA | | SortedList_Foreach | NativeAOT 10.0 | 45.332 ns | 5.98 | 48 B | NA | | Dictionary_AsIEnumerable_Foreach | NativeAOT 10.0 | 90.230 ns | 11.91 | 48 B | NA |
vvdev
12.07.2026 07:16Это вполне ожидаемо - АОТ код просто не может быть оптимизирован на столько же, на сколько может быть оптимизирован код JITa. Отсюда же и аллокации, и скорость.
Для исправления скорости интересно было бы попробовать скомпилировать с собранным заранее профилем - я в этом совершенно не имею опыта, но чисто логически - может и поможет. Может быть и с некоторыми аллокациями поможет за счёт guarded devirtualization.
Если проведёте эксперимент - поделитесь результатом, пожалуйста, для общего развития.

Geronom Автор
12.07.2026 07:16Спасибо за прогон! Результат ожидаемый: деабстракция из статьи — это в первую очередь работа JIT в рантайме (escape analysis + девиртуализация по горячему профилю), а NativeAOT компилирует всё заранее и такой информации не имеет. Поэтому бокс энумератора в интерфейсном foreach там остаётся как есть — те самые 48 B.
Частично помочь может профилируемая сборка: собрать профиль через dotnet-pgo и скормить его AOT-компиляции — часть девиртуализации станет возможной статически. Если руки дойдут проверить — очень интересно увидеть цифры. Возможно, добавлю раздел про AOT в статью или сделаю отдельную часть.

mynameco
12.07.2026 07:16если они так накосячили с интерфейсом, то они могут решить это все через атрибуты.
например создать атрибут, который будет подсказывать компилятору, какой тип там всегда возвращантся. и пометить им метод getenumerator.
а атрибут форс инлайна у них уже есть. пометить метод который в инумераторе в конце вызывается и все.
и интерфейс не поменяется и все оптимизации на этапе il кода будут работать
Lewigh
Спасибо за статью!
Но в ней есть одна небольшая неточность понимания или формулировки, усложняющая понимание этого механизма.
Вовсе не так. Нет никакой проблемы передать struct по ссылке. Проблема в другом - невозможно выделить на стеке struct, размер которого не знает компилятор. Это важное ограничение стека.
Здесь конкретный struct размер которого понятен компилятору. Размер
Tбудет определен для каждого конкретного типа, остальные размеры фиксированные. Можно выделять в стеке без проблем.А вот здесь уже, все что знает компилятор так это размер
T. В остальное в зависимости от реализации может гулять. Нельзя формировать стек размер которого будут гулять.Именно по этому, в данном случае, компилятор проводит боксинг т.е. делает из неизвестного размера struct известный - размер ссылки и решает эту проблему.
И это проклятье любого интерфейса за которым спрятан struct. Именно поэтому struct за дженериком ограниченный интерфейсом и не вызывает аллокаций а за интерфейсом вызывает.
Geronom Автор
боксинг не про размер! Даже стек там не светился!! sharpobject obj = 42; // размер известен — боксинг! void P(T num) { } // размер неизвестен боксинга нет! интерфейсу и object объект в куче, иначе виртуальные вызовы не работают. Вот struct и заворачивают
Lewigh
Окей. Какого размера
sharpobject? Наверное это int размером 4? Точно? А если так?Или так?
А если так?
Компилятор понятия не имеет что у вас будет лежать в object и поэтому вынужден использовать наиболее обобщенный тип - ссылку размером 8 байт размещая данные в куче.
Так как
Tэто дженерик а в C# дженерики специализируются то компилятору и не нужно знатьT, вместо него будет конкретный тип с конкретным размером.Вы не понимаете что просто не можете разместить struct находящийся за интерфейсом в стеке, потому что количество реализаций и следовательно размеров памяти которую потребуется выделить неопределенно?
Давайте на пальцах в том числе про виртуальные вызовы. Пример первый, с боксингом:
В методе
Fuбудет боксинг. Потому что нельзя создать такую функциюFuкоторая будет принимать первым параметром структуру любого размера. У нас в примере структураBимеет размер 4 байта а структура 0 байт. Поэтому компилятор вынужден приводить неизвестный размер к известному - ссылку в 8 байт. Затем действительно происходит виртуальный вызов.Второй пример, без боксинга:
Все тоже самое но боксинга нет. Потому что компилятор в данном случае сгенерирует два варианта функции
Fu2, который будут выглядеть примерно так:Никакой боксинг уже не нужен так как для каждого типа своя функция с известным размером. Никакие виртуальные вызовы тоже не нужны, потому как структура будет вызывать просто свой метод.
Вы можете спросить: а как же наследование без виртуальных функций? На что я отвечу что нет никакого наследования для struct. Если даже в интерфейсе будет дофлотная реализация метода а в структуре не будет ее реализации то вызов такого метода структуры будет просто прямым вызовом метода интерфейса.
Весь боксинг - это исключительно игра с размерами.
mvv-rus
Правильно спросить не “а как же наследование?”, а “а как же полиморфизм?”. Интерфейс - он ведь именно про полиморфизм, тоесть, про косвенные вызовы методов чере таблицу виртуальных методов. А наследовать там нечего: интерфейс - не класс, в нем (по крайней мере, в его изначальной идее) нет ничего своего, кроме контракта, то есть, набора этих самых виртуальных методов, включая get/set для свойств.
Так что, хотя от значимых типов (т.е. struct) наследовать нельзя, интерфейс обеспечивает для них полиморфизм: в коде можно использовать один и тот же метод для работы с разными значениями этих самых значимых типов, даже не зная, с каким именно типом код работает в данный момент.
И вот чтобы обеспечить этот самый полиморфизм, приходится для этого (в общем случае, деабстракции в JIT как раз позволяет это в конкретных случаях, где без полиморфизма можно обойтись, избегать) размещать значение значимого типа в куче - ибо ссылка на таблицу виртуальных методов ЕМНИП живет именно там, в заголовке объекта в куче.
Geronom Автор
Давайте по пунктам.
«Компилятор понятия не имеет, что у вас будет лежать в object». Имеет — в каждой точке боксинга тип известен статически. Ваш же пример в IL:
Каждая инструкция box эмитится для конкретного, известного компилятору типа (ECMA-335, III.4.1). «Неизвестного размера» здесь нет нигде. А то, что переменная object хранит 8-байтовую ссылку — это и есть определение из статьи: место хранения типа object/интерфейс держит ссылку на объект.
Если бы боксинг был «исключительно игрой с размерами», куча была бы не нужна. Managed pointer на struct в стеке — тоже фиксированные 8 байт.
Именно так работают ref/in и constrained.callvirt (ECMA-335, III.2.1): вызов интерфейсного метода на struct идёт по указателю, без единой аллокации.
Почему же ваш Fu(IA some, ...) так не может? Две причины, и обе не про размер.
Диспетчеризация. В Fu приходят и B, и C — обе ссылки по 8 байт. Как рантайм выбирает, чей operation звать? По method table в заголовке боксированного объекта. У «голого» struct заголовка нет — размер ссылки тут ничего не решает.
Время жизни. IA some можно сохранить в поле, в коллекцию, захватить в замыкание — ссылка обязана переживать фрейм вызова. Указатель на стек так не может. Вот настоящее «ограничение стека»: lifetime, а не неизвестный размер.
Сделайте обе ваши структуры ровно по 4 байта. Размеры известны и одинаковы — боксинг в Fu(IA) никуда не денется.
«Компилятор сгенерирует два варианта Fu2». Не компилятор, а JIT в рантайме — компилятор C# эмитит один generic-метод в IL, размер TA на этапе компиляции ему неизвестен. Это прямо противоречит вашей же схеме «компилятор должен знать размер»: размер неизвестен — боксинга нет.
И про «нет виртуальных вызовов для struct»: вызов some.operation через интерфейс на боксированной структуре — это самый настоящий виртуальный вызов через method table. Уберите его — и ваш Fu перестанет работать.
Определение боксинга — не моя интерпретация: «converting a value type to the type object or to any interface type... wraps the value inside a System.Object instance and stores it on the managed heap», ECMA-335 §III.4.1.
vvdev
Если я вас ни с кем не путаю, то уже во второй ветке я читаю эту вашу интерпретацию боксинга.
Так вот, она неверная.
Ниже вам верно ответили: боксинг - это превращение инстанса типа без заголовка (в .нете - всегда struct) в инстанс типа с заголовком (в .нете всегда любой ссылочный тип)
Интерфейс в .нете - всегда ссылочный тип с заголовком (вкл. vtable)
Не обязательно верить мне, достаточно прочитать о боксинге в документации .нета/clr.
Если же вы так уверены в своей интерпретации - можно получить подтверждающие ссылки на какие-либо авторитетные источники?