Уважаемые читатели, в этой статье я хочу рассказать о скрытых аллокациях в обычном 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 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;
    }
  }

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

Рис. 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 из первой) — пишите в комментариях с версией рантайма, это самое интересное.

Комментарии (12)


  1. Lewigh
    12.07.2026 07:16

    Спасибо за статью!

    Но в ней есть одна небольшая неточность понимания или формулировки, усложняющая понимание этого механизма.

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

    Вовсе не так. Нет никакой проблемы передать struct по ссылке. Проблема в другом - невозможно выделить на стеке struct, размер которого не знает компилятор. Это важное ограничение стека.

    var list = new List<int> { 1, 2, 3 };
     
    // Тип переменной - List<int>. foreach берет метод №1.
    // Struct-энумератор живет на стеке. Аллокаций: 0.
    foreach (var n in list) { }
    
    public struct Enumerator : IEnumerator<T>, IEnumerator
            {
                private readonly List<T> _list;
                private readonly int _version;
    
                private int _index;
                private T? _current;
      ...
    }

    Здесь конкретный struct размер которого понятен компилятору. Размер T будет определен для каждого конкретного типа, остальные размеры фиксированные. Можно выделять в стеке без проблем.

    // Тип переменной - IEnumerable<int>. foreach берет метод №2.
    // Тот же struct, но боксированный на кучу.
    // Аллокаций: 1 объект на каждый foreach.
    IEnumerable<int> seq = list;
    foreach (var n in seq) { }
    
    public interface IEnumerator<out T> : IDisposable, IEnumerator
        where T : allows ref struct
    {
        new T Current
        {
            get;
        }
    }

    А вот здесь уже, все что знает компилятор так это размер T. В остальное в зависимости от реализации может гулять. Нельзя формировать стек размер которого будут гулять.

    Именно по этому, в данном случае, компилятор проводит боксинг т.е. делает из неизвестного размера struct известный - размер ссылки и решает эту проблему.

    И это проклятье любого интерфейса за которым спрятан struct. Именно поэтому struct за дженериком ограниченный интерфейсом и не вызывает аллокаций а за интерфейсом вызывает.


    1. Geronom Автор
      12.07.2026 07:16

      боксинг не про размер! Даже стек там не светился!! sharpobject obj = 42; // размер известен — боксинг! void P(T num) { } // размер неизвестен боксинга нет! интерфейсу и object объект в куче, иначе виртуальные вызовы не работают. Вот struct и заворачивают


      1. Lewigh
        12.07.2026 07:16

        боксинг не про размер! Даже стек там не светился!! sharpobject obj = 42; // размер известен — боксинг!

        Окей. Какого размера sharpobject? Наверное это int размером 4? Точно? А если так?

        object obj = 42;
        obj = 42L; // ???

        Или так?

        object obj = 42;
        obj = 42L; // ???
        obj = "42"; // ???

        А если так?

        public static void Fu(object obj) {
        }

        Компилятор понятия не имеет что у вас будет лежать в object и поэтому вынужден использовать наиболее обобщенный тип - ссылку размером 8 байт размещая данные в куче.

         void P(T num) { } // размер неизвестен боксинга нет!

        Так как T это дженерик а в C# дженерики специализируются то компилятору и не нужно знать T, вместо него будет конкретный тип с конкретным размером.

        интерфейсу и object объект в куче, иначе виртуальные вызовы не работают. Вот struct и заворачивают

        Вы не понимаете что просто не можете разместить struct находящийся за интерфейсом в стеке, потому что количество реализаций и следовательно размеров памяти которую потребуется выделить неопределенно?

        Давайте на пальцах в том числе про виртуальные вызовы. Пример первый, с боксингом:

        public interface IA {
            int operation(int a, int b);
        }
        public record struct B(int X) : IA {
            public int operation(int a, int b) {
                return a + b + X;
            }
        }
        public struct C : IA {
            public int operation(int a, int b) {
                return a * b;
            }
        }
        public static int Fu(IA some, int a, int b) {
            return some.operation(a, b);
        }

        В методе Fu будет боксинг. Потому что нельзя создать такую функцию Fu которая будет принимать первым параметром структуру любого размера. У нас в примере структура B имеет размер 4 байта а структура 0 байт. Поэтому компилятор вынужден приводить неизвестный размер к известному - ссылку в 8 байт. Затем действительно происходит виртуальный вызов.

        Второй пример, без боксинга:

        public static int Fu2<TA>(TA some, int a, int b) where TA : IA {
            return some.operation(a, b);
        }

        Все тоже самое но боксинга нет. Потому что компилятор в данном случае сгенерирует два варианта функции Fu2, который будут выглядеть примерно так:

        public static int Fu2B(B some, int a, int b) {
            return some.operation(a, b);
        }
        public static int Fu2C(C some, int a, int b) {
            return some.operation(a, b);
        }

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

        Вы можете спросить: а как же наследование без виртуальных функций? На что я отвечу что нет никакого наследования для struct. Если даже в интерфейсе будет дофлотная реализация метода а в структуре не будет ее реализации то вызов такого метода структуры будет просто прямым вызовом метода интерфейса.

        Весь боксинг - это исключительно игра с размерами.


        1. mvv-rus
          12.07.2026 07:16

          Вы можете спросить: а как же наследование без виртуальных функций? На что я отвечу что нет никакого наследования для struct.

          Правильно спросить не “а как же наследование?”, а “а как же полиморфизм?”. Интерфейс - он ведь именно про полиморфизм, тоесть, про косвенные вызовы методов чере таблицу виртуальных методов. А наследовать там нечего: интерфейс - не класс, в нем (по крайней мере, в его изначальной идее) нет ничего своего, кроме контракта, то есть, набора этих самых виртуальных методов, включая get/set для свойств.

          Так что, хотя от значимых типов (т.е. struct) наследовать нельзя, интерфейс обеспечивает для них полиморфизм: в коде можно использовать один и тот же метод для работы с разными значениями этих самых значимых типов, даже не зная, с каким именно типом код работает в данный момент.

          И вот чтобы обеспечить этот самый полиморфизм, приходится для этого (в общем случае, деабстракции в JIT как раз позволяет это в конкретных случаях, где без полиморфизма можно обойтись, избегать) размещать значение значимого типа в куче - ибо ссылка на таблицу виртуальных методов ЕМНИП живет именно там, в заголовке объекта в куче.


        1. Geronom Автор
          12.07.2026 07:16

          Давайте по пунктам.

          1. «Компилятор понятия не имеет, что у вас будет лежать в object». Имеет — в каждой точке боксинга тип известен статически. Ваш же пример в IL:

            box  [System.Runtime]System.Int32   // obj = 42
            box  [System.Runtime]System.Int64   // obj = 42L

            Каждая инструкция box эмитится для конкретного, известного компилятору типа (ECMA-335, III.4.1). «Неизвестного размера» здесь нет нигде. А то, что переменная object хранит 8-байтовую ссылку — это и есть определение из статьи: место хранения типа object/интерфейс держит ссылку на объект.

          2. Если бы боксинг был «исключительно игрой с размерами», куча была бы не нужна. 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) никуда не денется.

          3. «Компилятор сгенерирует два варианта 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.


    1. vvdev
      12.07.2026 07:16

      Если я вас ни с кем не путаю, то уже во второй ветке я читаю эту вашу интерпретацию боксинга.

      Так вот, она неверная.

      Ниже вам верно ответили: боксинг - это превращение инстанса типа без заголовка (в .нете - всегда struct) в инстанс типа с заголовком (в .нете всегда любой ссылочный тип)

      Интерфейс в .нете - всегда ссылочный тип с заголовком (вкл. vtable)

      Не обязательно верить мне, достаточно прочитать о боксинге в документации .нета/clr.

      Если же вы так уверены в своей интерпретации - можно получить подтверждающие ссылки на какие-либо авторитетные источники?


  1. alex3696
    12.07.2026 07:16

    было интересно


    1. 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 |


      1. vvdev
        12.07.2026 07:16

        Это вполне ожидаемо - АОТ код просто не может быть оптимизирован на столько же, на сколько может быть оптимизирован код JITa. Отсюда же и аллокации, и скорость.

        Для исправления скорости интересно было бы попробовать скомпилировать с собранным заранее профилем - я в этом совершенно не имею опыта, но чисто логически - может и поможет. Может быть и с некоторыми аллокациями поможет за счёт guarded devirtualization.

        Если проведёте эксперимент - поделитесь результатом, пожалуйста, для общего развития.


      1. Geronom Автор
        12.07.2026 07:16

        Спасибо за прогон! Результат ожидаемый: деабстракция из статьи — это в первую очередь работа JIT в рантайме (escape analysis + девиртуализация по горячему профилю), а NativeAOT компилирует всё заранее и такой информации не имеет. Поэтому бокс энумератора в интерфейсном foreach там остаётся как есть — те самые 48 B.

        Частично помочь может профилируемая сборка: собрать профиль через dotnet-pgo и скормить его AOT-компиляции — часть девиртуализации станет возможной статически. Если руки дойдут проверить — очень интересно увидеть цифры. Возможно, добавлю раздел про AOT в статью или сделаю отдельную часть.


  1. Geronom Автор
    12.07.2026 07:16

    Спасибо!


  1. mynameco
    12.07.2026 07:16

    если они так накосячили с интерфейсом, то они могут решить это все через атрибуты.

    например создать атрибут, который будет подсказывать компилятору, какой тип там всегда возвращантся. и пометить им метод getenumerator.

    а атрибут форс инлайна у них уже есть. пометить метод который в инумераторе в конце вызывается и все.

    и интерфейс не поменяется и все оптимизации на этапе il кода будут работать