Обычный new внутри метода. Объект создаётся, используется и больше нигде не встречается.
StrongBox<int> box = new StrongBox<int>(seed); box.Value += 1; return box.Value;
На.NET 9 и.NET 10 память под него не выделяется: JIT удаляет объект, и счётчик выделенной памяти показывает ноль.
Похожих мест в CoreLib много. Ниже разобрано пять:
new, после которого в куче не появляется объекта;
метод, который всегда возвращает false;
двенадцать проверок типа подряд в одном методе;
очистка списка, при которой список не очищается;
проверка поддержки векторов внутри метода.
Все разделы построены одинаково: исходник, результат компиляции, причина, ошибки при замере.
Машина | Процессор | Система |
Комп 1 | Intel Core i9-10900KF 3.70GHz, 10 ядер | Windows 10 22H2 |
Комп 2 | AMD Ryzen 9 5950X 3.39GHz, 16 ядер | Windows 10 1809 |
Комп 3 | Intel Xeon W-2255 3.70GHz, 10 ядер | Windows Server 2022 |
Комп 4 | Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра | Windows Server 2022 |
Все машины x64, на arm64 замеры не снимались. Рантаймы 8.0.11–8.0.30, 9.0.4–9.0.19, 10.0.1–10.0.11 — все три в одном запуске BenchmarkDotNet 0.15.8 с DisassemblyDiagnoser. SDK 11 — предварительная сборка, к релизу числа могут измениться.
1. new, после которого в куче не появляется объекта
Исходник
Поля, куда записываются ссылки:
private static StrongBox<int>? _escapedBox; private static int[]? _escapedArray;
Два метода. В первом объект не выходит за пределы вызова, во втором ссылка записывается в статическое поле:
[MethodImpl(MethodImplOptions.NoInlining)] internal static int BoxStays(int seed) { StrongBox<int> box = new StrongBox<int>(seed); box.Value += 1; return box.Value; } [MethodImpl(MethodImplOptions.NoInlining)] internal static int BoxEscapes(int seed) { StrongBox<int> box = new StrongBox<int>(seed); box.Value += 1; _escapedBox = box; return box.Value; }
Та же пара с массивом на четыре элемента:
[MethodImpl(MethodImplOptions.NoInlining)] internal static int ArrayStays(int seed) { int[] buffer = new int[4]; buffer[0] = seed; buffer[1] = seed + 1; buffer[2] = seed + 2; buffer[3] = seed + 3; return buffer[0] + buffer[1] + buffer[2] + buffer[3]; } [MethodImpl(MethodImplOptions.NoInlining)] internal static int ArrayEscapes(int seed) { int[] buffer = new int[4]; buffer[0] = seed; buffer[1] = seed + 1; buffer[2] = seed + 2; buffer[3] = seed + 3; _escapedArray = buffer; return buffer[0] + buffer[1] + buffer[2] + buffer[3]; }
Результат компиляции
Размер машинного кода и объём выделенной памяти для обеих пар:
Что измерялось | NET 8 | NET 9 | NET 10 |
объект остаётся в методе, машинный код | 50 Б | 13 Б | 13 Б |
объект остаётся в методе, выделено | 24 Б | 0 | 0 |
массив остаётся в методе, машинный код | 71 Б | 71 Б | 104 Б |
массив остаётся в методе, выделено | 40 Б | 40 Б | 0 |
Результат одинаков на всех четырёх машинах, до байта. Объект, записанный в поле, занимает 24 байта на всех трёх рантаймах, массив — 40
Тринадцать байт машинного кода — это две инструкции:
lea eax, [rcx+1] ret
Объекта нет ни в куче, ни на стеке: сработала скалярная замена — поле объекта стало обычной локальной переменной, а сам тип в машинный код не попал. У массива на.NET 10 иначе: он размещён на стеке, вызова аллокатора нет, но сам массив никуда не делся — отсюда 104 байта машинного кода против 71 на прежних версиях.
Время подтверждает то же самое, но с оговоркой. Объект перестаёт выделяться на.NET 9:
Машина | NET 8 | NET 9 |
Комп 1 | 2,50 ± 0,01 | 0,41 ± 0,01 |
Комп 2 | 4,16 ± 1,57 | 0,43 ± 0,25 |
Комп 3 | 3,56 ± 0,21 | 0,52 ± 0,06 |
Комп 4 | 4,67 ± 0,29 | 0,74 ± 0,01 |
Наносекунды, среднее и стандартное отклонение. Объект остаётся в методе. На.NET 10 числа того же порядка: 0,40, 0,58, 0,60 и 0,74
Для массива это заработало версией позже:
Машина | NET 9 | NET 10 |
Комп 1 | 3,16 ± 0,04 | 0,93 ± 0,01 |
Комп 2 | 4,43 ± 1,42 | 0,41 ± 0,28 |
Комп 3 | 4,85 ± 0,41 | 1,14 ± 0,10 |
Комп 4 | 5,87 ± 0,13 | 1,13 ± 0,04 |
Наносекунды, среднее и стандартное отклонение. Массив остаётся в методе. На.NET 8 числа того же порядка, что на.NET 9: 3,23, 4,18, 5,35 и 7,83
Стандартное отклонение доходит до 70% среднего на AMD Ryzen 9 5950X и до 12% на Intel Xeon W-2255, на остальных двух машинах не выше 6%. Поэтому такие числа показывают только направление изменения. Величину подтверждают размер машинного кода и объём выделенной памяти — на всех четырёх машинах они одинаковы.
Причина
Escape‑анализ: если JIT доказал, что объект не покидает метод, размещать его в куче не нужно. Удаление объекта появилось в.NET 9, размещение массива на стеке — в.NET 10. На.NET 8 нет ни того, ни другого.
Следствие для замеров: колонка Allocated больше не показывает, есть ли в методе new. Результаты, снятые до.NET 9, на новых рантаймах надо перепроверять.
Ошибки при замере
Тот же результат получается через счётчик рантайма в обычном цикле, без BenchmarkDotNet — GC.GetAllocatedBytesForCurrentThread:
long before = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 1_000_000; i++) { _ = Subjects.BoxStays(i); } long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Что измерялось | NET 8 | NET 9 | NET 10 |
объект остаётся в методе | 24 000 000 | 0 | 0 |
массив остаётся в методе | 40 000 000 | 40 000 000 | 0 |
Миллион вызовов, многоуровневая компиляция выключена. Одинаково на всех четырёх машинах
Многоуровневую компиляцию надо выключать переменной DOTNET_TieredCompilation: размещение на стеке и удаление объекта работают только на втором уровне. Иначе в отчёт попадёт смесь двух уровней, и он покажет 24 миллиона байт там, где память не выделяется.
Поле, в которое записывается ссылка, должно где‑то читаться. Запись в поле, которое никто не читает, JIT вправе удалить, и второй метод перестанет измерять то, что заявлено.
2. Метод, который всегда возвращает false
Исходник
У метода четыре перегрузки, все объявлены в RuntimeHelpers одной строкой каждая:
[Intrinsic] internal static bool IsKnownConstant(string? t) => false; [Intrinsic] internal static bool IsKnownConstant(Type? t) => false; [Intrinsic] internal static bool IsKnownConstant(char t) => false; [Intrinsic] internal static bool IsKnownConstant<T>(T t) where T : struct => false;
Тело метода в машинный код не попадает. При компиляции JIT заменяет вызов на константу: true, если аргумент после встраивания оказался константой, false в остальных случаях. Дальше свёртка констант удаляет недостижимые ветки, и от вызова не остаётся ни одной инструкции. Тот же механизм есть в GCC под именем __builtin_constant_p.
Результат этого метода String использует для выбора между двумя реализациями сравнения: одна рассчитана на литерал, вторая на произвольную строку.
Результат компиляции
Два одинаковых метода. Разница одна: во втором строка для сравнения приходит параметром.
[MethodImpl(MethodImplOptions.NoInlining)] internal static bool EqualsLiteral(string value) => value == "content-length"; [MethodImpl(MethodImplOptions.NoInlining)] internal static bool EqualsOpaque(string value, string marker) => value == marker;
Метод | IL, байт | Машинный код, байт |
EqualsLiteral | 12 | 134–158 |
EqualsOpaque | 8 | 453–482 |
IL — промежуточный язык со стековой моделью, в который компилирует C#. У метода с литералом тело в IL больше, а машинного кода получается втрое меньше
Здесь компилятор ничего не оптимизирует. Работу делает JIT: зная содержимое второго операнда, он разворачивает сравнение в набор инструкций под конкретную длину строки вместо вызова общей процедуры.
Метод | NET 8 | NET 9 | NET 10 |
EqualsLiteral | 27 248,81 ± 188,40 | 31 161,86 ± 259,81 | 36 075,52 ± 2 339,19 |
EqualsOpaque | 61 866,99 ± 469,65 | 58 596,45 ± 488,35 | 53 345,31 ± 249,86 |
Наносекунды, среднее и стандартное отклонение. 16 384 строки, совпадающие со вторым операндом, AMD Ryzen 9 5950X. Пятнадцать измерений в каждой ячейке — значение BenchmarkDotNet по умолчанию
Сравнение с переменной медленнее сравнения с литералом в 1,48–2,38 раза. Различие между машинами больше, чем между версиями рантайма.
Перегрузку с char вызывает String.StartsWith(char):
public bool StartsWith(char value) { if (RuntimeHelpers.IsKnownConstant(value) && value != '\0') { return _firstChar == value; } return Length != 0 && _firstChar == value; }
Обе ветки дают один результат, но первая не проверяет длину. Она применима, только если символ известен при компиляции и не нулевой: у пустой строки поле первого символа содержит завершающий ноль, и проверка на '\0' вернула бы true.
Разницу можно увидеть при сравнении с перегрузкой, которая принимает строку. Она устроена иначе и этот интринсик не вызывает, поэтому числа ниже показывают не только его влияние:
value.StartsWith('c') value.StartsWith("c", StringComparison.Ordinal)
Запись | NET 8 | NET 9 | NET 10 | Машинный код |
StartsWith(char) | 20 292,62 ± 145,78 | 23 390,41 ± 388,84 | 23 197,13 ± 134,84 | 89–91 Б |
StartsWith(string) | 27 142,36 ± 191,94 | 28 319,04 ± 2 090,53 | 26 923,46 ± 201,84 | 99–101 Б |
Наносекунды, среднее и стандартное отклонение. 16 384 строки, AMD Ryzen 9 5950X. Перегрузка с символом быстрее на всех трёх версиях
Причина
Отдельную реализацию для литерала надо где‑то описать. Написать её в C++ компилятора означало бы прописать там конкретные методы String. Атрибут [Intrinsic] позволяет описать выбор на C#, внутри библиотеки: JIT отвечает только на вопрос, известно ли значение при компиляции. Дальше срабатывают свёртка констант и удаление недостижимого кода, и в машинный код проверка не попадает.
Ошибки при замере
Строку для сравнения надо передавать параметром, а не хранить в статическом поле. Поле только для чтения JIT вправе свернуть в константу после инициализации типа — тогда оба метода скомпилируются в одно и то же.
Набор из пустых строк для проверки не подходит. Строка нулевой длины в рантайме одна на весь процесс, оба метода останавливаются на сравнении ссылок и до содержимого не доходят: 26 639,35 против 26 791,20 наносекунды на.NET 10.
3. Двенадцать проверок типа подряд
Исходник
В обобщённом коде библиотеки встречаются такие последовательности проверок. Work — невстраиваемый метод со счётчиком вызовов, один на все варианты:
internal static int Ladder<T>(T value) { if (typeof(T) == typeof(string)) { return Work(1); } if (typeof(T) == typeof(object)) { return Work(2); } if (typeof(T) == typeof(Uri)) { return Work(3); } // ещё девять таких же проверок return Work(value is null ? 0 : 13); }
Результат компиляции
Для каждого значимого типа JIT компилирует отдельную версию обобщённого метода, вычисляет каждую проверку при компиляции и оставляет код одной ветки.
Для всех ссылочных типов версия одна, она обозначается __Canon. Проверки со значимыми типами JIT и здесь вычисляет как false и удаляет: этот код работает только со ссылочными типами. Проверки со ссылочными типами вычислить нельзя — T может оказаться любым из них, поэтому все двенадцать проверок остаются в машинном коде.
Версия метода | Машинный код, байт |
Ladder<int> | 21 |
Ladder<__Canon> | 403–445 |
Один и тот же метод, 431 байт в IL
Параметр типа | Комп 1 | Комп 2 | Комп 3 | Комп 4 |
значимый тип | 32 804,9 ± 363,7 | 45 646,6 ± 8 243,2 | 42 381,2 ± 443,0 | 48 425,4 ± 1 437,3 |
типа нет в проверках | 65 870,8 ± 727,4 | 74 078,8 ± 14 015,4 | 93 624,7 ± 789,0 | 105 686,9 ± 1 359,8 |
Наносекунды, среднее и стандартное отклонение. 16 384 вызова,.NET 10. Разница составляет от 1,62 до 2,21 раза
Чтобы работа после проверок не влияла на результат, все варианты сведены к одному невстраиваемому методу со счётчиком вызовов. Unlisted — ссылочный тип, которого в проверках нет, на нём выполняются все двенадцать. Отдельный отчёт выводит:
Ladder<int> 100000 Ladder<Unlisted> 100000 Ladder<string> 100000 Ladder<object> 100000
Работа одинаковая, данные одинаковые, различается только число выполненных проверок типа.
Причина
Так пишут перегрузки для примитивов, не дублируя исходник. Один метод обслуживает десяток типов, а в машинном коде для каждого значимого типа остаётся только нужная ветка.
Ошибки при замере
Сравнивать надо со ссылочными типами. Общая версия метода работает только со ссылочными типами, поэтому проверку вида typeof(T) == typeof(int) JIT вычисляет как false в любой версии. Вся цепочка удаляется во всех вариантах, и замер даст одинаковые числа там, где должен дать разные.
4. Очистка списка, при которой список не очищается
Исходник
Метод List<T>.Clear проверяет, содержит ли T ссылки:
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Clear() { _version++; if (RuntimeHelpers.IsReferenceOrContainsReferences<T>()) { int size = _size; _size = 0; if (size > 0) { Array.Clear(_items, 0, size); } } else { _size = 0; } }
Результат компиляции
Проверки в машинном коде нет. IsReferenceOrContainsReferences<T>() — тоже интринсик: для каждого значимого типа JIT компилирует отдельную версию метода и вычисляет условие, для ссылочных ответ известен заранее. Остаётся код одной ветки.
Условие проверяет не «ссылочный тип против значимого», а наличие ссылок в самом типе. Чтобы это показать, нужны четыре списка: числа, строки, структура из двух int и структура с полем string.
Список | Время очистки, нс |
List<int> | 794,85 ± 823,69 |
List<PointValue>, структура без ссылок | 347,83 ± 365,09 |
List<string> | 658 540,00 ± 92 079,09 |
List<PairValue>, структура со ссылкой | 1 230 371,88 ± 166 353,28 |
Среднее и стандартное отклонение. Миллион элементов,.NET 10, AMD Ryzen 9 5950X
У первых двух строк стандартное отклонение сопоставимо со средним, поэтому различить их нельзя: обе очистки не трогают массив и выполняются за постоянное время независимо от размера. Разница в 794,85 и 347,83 наносекунды — погрешность измерения, а не свойство типа. Подтверждение: на одной из четырёх машин порядок обратный, список чисел там очищается быстрее списка структур.
Значимые типы дали разные результаты: PointValue попал в одну группу с числами, PairValue — со строками, и очищается в 1,87 раза медленнее списка строк: элемент PairValue занимает 16 байт против 8 у ссылки, поэтому массив под обнуление вдвое больше.
Время очистки списка чисел не зависит от размера: 808,51 наносекунды на ста тысячах элементов и 794,85 на миллионе, разница в пределах погрешности. Массив остаётся как есть, обнуляется только счётчик.
Причина
Массив обнуляется не для того, чтобы стереть значения, а для сборщика мусора: пока в нём остаются ссылки, объекты считаются достижимыми и память не освобождается. У чисел удерживать нечего, поэтому проход по массиву здесь лишний.
Ошибки при замере
Замер стирает данные, поэтому идёт по одному вызову на измерение. Восстановление вынесено в подготовку итерации и памяти не выделяет: вместимость списка сохраняется, меняется только счётчик.
Минус такого подхода — низкое разрешение. У списков, которые оставляют массив как есть, стандартное отклонение сравнимо со средним. По таким числам видно, постоянное время или линейное, но сравнивать эти списки между собой нельзя.
5. Проверка поддержки векторов внутри метода
Исходник
Свойство Vector.IsHardwareAccelerated проверяется в теле метода, который вызывается миллионы раз:
[MethodImpl(MethodImplOptions.NoInlining)] internal static int SumGuarded(int[] data) { int total = 0; int index = 0; if (Vector.IsHardwareAccelerated && data.Length >= Vector<int>.Count) { Vector<int> sum = Vector<int>.Zero; for (; index <= data.Length - Vector<int>.Count; index += Vector<int>.Count) { sum += new Vector<int>(data, index); } total = Vector.Dot(sum, Vector<int>.One); } for (; index < data.Length; index++) { total += data[index]; } return total; }
Результат компиляции
Условие вычисляется при компиляции, в машинный код оно не попадает. Поддержка векторов за время работы процесса не меняется, JIT подставляет константу и удаляет недостижимый код.
Рантайм | С проверкой | Скалярная | Во сколько раз |
NET 8 | 1 077,71 ± 113,86 | 5 463,07 ± 1 274,82 | 5,07 |
NET 9 | 1 211,26 ± 240,69 | 5 249,88 ± 1 259,38 | 4,33 |
NET 10 | 1 052,75 ± 113,14 | 5 343,84 ± 850,66 | 5,08 |
Наносекунды, среднее и стандартное отклонение. Сумма массива из 16 384 элементов, AMD Ryzen 9 5950X
Размер машинного кода: метод с проверкой 143–175 байт, скалярный 34. Разница во времени возникает не из‑за проверки, а из‑за развёрнутого векторного цикла.
Причина
Один и тот же код должен работать и на процессоре с векторными инструкциями, и на процессоре без них. Поэтому в исходнике написаны обе реализации, а JIT при компиляции оставляет ту, которая подходит этому процессору, и удаляет вторую.
Ошибки при замере
BenchmarkDotNet прогревает метод перед измерением, поэтому первый уровень компиляции в отчёт не попадает. А там машинный код другой: интринсики ещё не подставлены. Нужен отдельный замер.
Метод суммирования вызывается 8000 раз: 40 партий по 200 вызовов, массив 1024 элемента. Время каждой партии снимается через Stopwatch.ElapsedTicks и выводится в тиках. AMD Ryzen 9 5950X,.NET 10, переменные окружения не заданы:
Партия по 200 вызовов, тики на партию: партия 1 11,739 партия 2 1,723 партия 3 1,540 ... Первая партия: 11739 Последняя партия: 1273 Отношение: 9.22
На всех четырёх машинах первая партия медленнее последней в 8,66–9,46 раза. Это и есть переход на второй уровень компиляции.
Что ещё есть в исходниках
Таких мест в исходниках больше. Числа для них не снимались — только ссылка на строку.
Array.cs: порог перехода на сортировку вставками равен 16. В комментарии сказано, что число подобрано опытным путём на массивах int, а для крупных структур порог стоило бы взять ниже.
HashHelpers.cs: после сотни коллизий в одном бакете словарь со строковыми ключами переходит на рандомизированный хеш, пересчитывает все хеш‑коды и перестраивает таблицу в тот же размер.
Dictionary.cs: бакеты пронумерованы с единицы, чтобы пустое значение совпало с нулём и при перестроении не пришлось проходить по массиву.
Stream.cs: буфер копирования равен 81 920 байт — наибольшее кратное 4096, которое меньше порога кучи больших объектов в 85 000.
ReadOnlySequence.Helpers.cs: избыточная проверка типа оставлена намеренно, чтобы JIT удалил недостижимый код. В файле встречается трижды.
ImmutableArray_1.Minimal.cs: индексатор намеренно не проверяет внутреннее поле на null и падает с NullReferenceException — иначе JIT не уберёт проверку границ.
Код из статьи
JitFoldProof — замеры, счётчик вызовов, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!

