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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
На какой версии .NET крутится ваш прод?
10.81%.NET Framework (да, всё ещё)4
5.41%.NET 6/72
16.22%.NET 86
8.11%.NET 93
54.05%.NET 1020
5.41%Несколько версий одновременно2
Проголосовали 37 пользователей. Воздержались 3 пользователя.