Замеры к статье «А чё, так можно было? new ≠ память» — пять мест в исходниках .NET, где написанный код в машинный не попадает.
Проект показывает, что именно исчезает при компиляции, на чём это видно и с какой версии рантайма работает.
BenchmarkDotNet 0.15.8, Release, .NET 8, .NET 9 и .NET 10 в одном запуске, со снятием машинного кода.
Статья: А чё, так можно было? new ≠ память
| Раздел | Место в исходниках | Что сравнивается |
|---|---|---|
| 1 | escape-анализ | new, объект из которого не выходит за пределы вызова, против уходящего в статическое поле |
| 2 | RuntimeHelpers.IsKnownConstant — метод, всегда возвращающий false |
сравнение с литералом против сравнения со строкой из параметра |
| 3 | цепочка typeof(T) == typeof(...) |
двенадцать проверок типа: значимый тип, совпадение на первой ветке, на второй и без совпадения |
| 4 | RuntimeHelpers.IsReferenceOrContainsReferences<T>() в List<T>.Clear |
очистка четырёх списков: числа, ссылки, значимый тип без ссылок и со ссылкой |
| 5 | Vector.IsHardwareAccelerated внутри метода |
цена проверки и переход на второй уровень компиляции |
| Машина | Процессор | Система |
|---|---|---|
| Комп 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. SDK 11 — предварительная сборка, к релизу числа могут измениться.
Выгрузки всех четырёх машин лежат в Results: Comp_1 — Intel Core i9-10900KF,
Comp_2 — AMD Ryzen 9 5950X, Comp_3 — Intel Xeon W-2255, Comp_4 —
Intel Xeon Silver 4314. Журналы прогона в репозиторий не вошли: они по мегабайту
и нужны только при разборе сбоя.
Размер машинного кода и объём выделенной памяти. Совпало на всех четырёх машинах до байта.
| Что измерялось | .NET 8 | .NET 9 | .NET 10 |
|---|---|---|---|
| объект остаётся в методе, машинный код | 50 Б | 13 Б | 13 Б |
| объект остаётся в методе, выделено | 24 Б | 0 | 0 |
| массив остаётся в методе, машинный код | 71 Б | 71 Б | 104 Б |
| массив остаётся в методе, выделено | 40 Б | 40 Б | 0 |
Тринадцать байт машинного кода — это lea eax, [rcx+1] и ret: сработала
скалярная замена, поле объекта стало обычной локальной переменной. Массив
на .NET 10 размещается на стеке, вызова аллокатора нет.
Тот же результат без BenchmarkDotNet, миллион вызовов, многоуровневая компиляция выключена:
| Что измерялось | .NET 8 | .NET 9 | .NET 10 |
|---|---|---|---|
| объект остаётся в методе | 24 000 000 | 0 | 0 |
| массив остаётся в методе | 40 000 000 | 40 000 000 | 0 |
Удаление объекта появилось в .NET 9, размещение массива на стеке — в .NET 10.
16 384 строки, совпадающие со вторым операндом, AMD Ryzen 9 5950X. Наносекунды, среднее и стандартное отклонение.
| Метод | .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 |
Сравнение с переменной медленнее в 1,48–2,38 раза по всем машинам и версиям.
| Метод | IL, байт | Машинный код, байт |
|---|---|---|
EqualsLiteral |
12 | 134–158 |
EqualsOpaque |
8 | 453–482 |
У метода с литералом тело в IL больше, а машинного кода получается втрое меньше.
Один и тот же метод, 431 байт в IL.
| Версия метода | Машинный код, байт |
|---|---|
Ladder<int> |
21 |
Ladder<__Canon> |
403–445 |
16 384 вызова, .NET 10, наносекунды со стандартным отклонением.
| Параметр типа | Комп 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 |
Разница от 1,62 до 2,21 раза. Число обращений к полезной работе у всех
вариантов одинаково — по 100 000, это проверяет отчёт counters.
Миллион элементов, .NET 10, AMD Ryzen 9 5950X.
| Список | Время очистки, нс |
|---|---|
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 |
Два значимых типа оказались по разные стороны: важно не «ссылочный тип против значимого», а наличие ссылок в самом типе. У первых двух строк стандартное отклонение сопоставимо со средним, различать их между собой нельзя — обе очистки оставляют массив как есть.
Сумма массива из 16 384 элементов, AMD Ryzen 9 5950X.
| Рантайм | С проверкой | Скалярная | Во сколько раз |
|---|---|---|---|
| .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 |
Размер машинного кода: метод с проверкой 143–175 байт, скалярный 34. Самой проверки в машинном коде нет, разница во времени возникает из-за развёрнутого векторного цикла.
Переход на второй уровень компиляции виден отчётом warmup: первая партия
медленнее последней в 8,66–9,46 раза на всех четырёх машинах.
Нужны SDK .NET 8, 9 и 10: BenchmarkDotNet поднимает по процессу на рантайм.
dotnet --list-sdks
В пути к проекту не должно быть запятых и точек с запятой. BenchmarkDotNet собирает вспомогательный проект и передаёт путь в MSBuild без кавычек, тот разбирает эти знаки как разделители списка свойств и падает с MSB1006. Отчёты при этом снимаются нормально, а замеры молча не выполняются. Батник проверяет путь и останавливается сразу.
Весь прогон одной командой:
all.bat
Скрипт собирает проект с -warnaserror, прогоняет сверку на трёх рантаймах
и останавливается, если она не прошла, снимает все отчёты в шести режимах,
делает две пробы на одном классе — без снятия машинного кода и с ним — и только
потом запускает замеры целиком. Всё складывается в Results\<имя машины>,
журнал прогона забирается туда же и при удаче, и при сбое.
Две пробы нужны, чтобы отделить сбой самого замера от сбоя дизассемблера:
если probe_noasm.txt проходит, а probe_asm.txt нет — чинить надо
дизассемблер, а не замер.
Вручную, без скрипта:
dotnet run -c Release -f net10.0 -- checks
dotnet run -c Release -f net10.0 -- counters
dotnet run -c Release -f net10.0 -- ilsize
dotnet run -c Release -f net10.0 -- alloc
dotnet run -c Release -f net10.0 -- warmup
dotnet run -c Release -f net10.0 -- timing
dotnet run -c Release -f net10.0 -- --filter *
dotnet run -c Release -f net10.0 -- --filter *EscapeBench*
Аргумент noasm выключает снятие машинного кода. Батник им пользуется сам,
руками он нужен, только если разбираешь конкретный класс:
dotnet run -c Release -f net10.0 -- --filter *EscapeBench* noasm
Причина любого сбоя лежит в Bdn\JitFoldProof.log: он пишется всегда
и содержит вывод дочернего процесса целиком.
Все измеряемые методы лежат в Subjects.cs, у каждого NoInlining: иначе
компилятор встроит метод в тело замера и удалит как ненужную работу.
На проверяемое это не влияет — вычисление константы и выбор версии метода
по типу происходят внутри самого метода, а не на границе с вызывающим.
Подготовка вынесена в GlobalSetup. В теле замера только то, что измеряется.
ClearBench стирает данные, поэтому помечен RunOncePerIteration: один вызов
на измерение, развёртка цикла отключена, а восстановление стоит
в IterationSetup. Восстановление не выделяет память:
CollectionsMarshal.SetCount возвращает счётчик элементов на место, не трогая
массив, вместимость уже выделена. Иначе столбец Allocated показывал бы
подготовку, а не очистку.
Строка для сравнения в разделе 2 приходит параметром, а не из статического поля. Поле только для чтения JIT вправе свернуть в константу после инициализации типа, и тогда оба метода пары скомпилировались бы в одно и то же.
Пустой строки в двух экземплярах не бывает: строка нулевой длины в рантайме
одна на весь процесс, любая попытка создать новую возвращает её же. Поэтому
пара EmptyByLiteral и EmptyByOpaque сравнивает один и тот же объект,
и измеряет она ровно одно — вычислена строка при компиляции или нет.
Проверки типа в разделе 3 идут со ссылочными типами, а не со значимыми.
Общая версия метода работает только со ссылочными типами, поэтому проверку
вида typeof(T) == typeof(int) JIT вычисляет как false в любой версии, вся
цепочка удаляется во всех вариантах, и замер даёт одинаковые числа там, где
должен дать разные.
Поля, куда записываются ссылки в разделе 1, читаются сверкой. Запись в поле, которое никто не читает, JIT вправе удалить, и второй метод пары перестал бы измерять то, что заявлено.
Счётчик обращений к полезной работе включён всегда, у всех вариантов раздела 3. Это осознанный размен: одно увеличение счётчика попадает в тело замера, зато возражение «дело в самой работе» закрывается числом. Расход одинаков у всех вариантов и на сравнение не влияет.
| Что проверялось | Чем |
|---|---|
| Работа под измеряемым кодом одинакова | отчёт counters: у всех вариантов раздела 3 по 100 000 обращений |
| Результат не зависит от подбора данных | четыре набора строк: совпадающие, с общим началом, расходящиеся с первого символа и пустые; для раздела 4 — четыре типа элемента |
| Результат не зависит от размера | каждый замер на трёх размерах |
| Дело не в конкретном типе | PointValue и PairValue оба значимые, а результаты у них разные |
| Дело не в форме записи | три записи проверки на пустоту и две записи проверки первого символа |
| Варианты считают одно и то же | отчёт checks, останавливает прогон при расхождении |
| Результат не зависит от инструмента | отчёты timing и alloc работают без BenchmarkDotNet, три прохода с печатью разброса |
| Дело не в динамическом профиле | режим DOTNET_TieredPGO=0 |
| Дело не в многоуровневой компиляции | режимы DOTNET_TieredCompilation=0 и DOTNET_ReadyToRun=0, отчёт warmup |
| Размещение на стеке подтверждено | режим DOTNET_JitObjectStackAllocation=0, отчёт alloc печатает значение переменной и сам сравнивает пары |
| Дело не в сборщике мусора | режим DOTNET_gcServer=1 |
| Дело не в конкретной машине | четыре машины, выгрузки всех четырёх в Results |
| Дело не в конкретной версии | .NET 8, 9 и 10 в одном запуске |
Числа alloc в обычном режиме читать нельзя. Размещение на стеке и удаление
объекта работают только на втором уровне компиляции, а прогрева в отчёте
не всегда хватает, чтобы метод туда попал. Обычный режим показывает смесь двух
уровней. Опорным считается alloc_netN.0_notiered.txt, там многоуровневая
компиляция выключена и числа означают то, что написано.
Чего в батнике нет:
- прогон на второй архитектуре. Все режимы архитектурно нейтральны, но результаты разделов 2 и 5 к архитектуре чувствительны. Все четыре машины x64, поэтому обобщать на arm64 нельзя.
- дизассемблер на Linux требует установленного
perf. Выключается аргументомnoasm.
Bdn\results\ отчёты BenchmarkDotNet: csv, md, html, машинный код
Bdn\JitFoldProof.log журнал прогона: причины сбоев только здесь
Results\Comp_N\
checks_netN.0.txt сверка, роняет прогон при расхождении
counters_netN.0.txt обращения к полезной работе
ilsize_netN.0.txt размер тела метода в байтах IL
alloc_netN.0.txt выделено байт, без BenchmarkDotNet
warmup_netN.0.txt время по партиям, переход на второй уровень
timing_netN.0.txt замер на секундомере, три прохода
alloc_netN.0_nopgo.txt то же без динамического профиля
alloc_netN.0_notiered.txt то же без многоуровневой компиляции
alloc_netN.0_noobjstack.txt то же без размещения объектов на стеке
alloc_netN.0_servergc.txt то же на серверном сборщике
warmup_netN.0_nor2r.txt то же без готового машинного кода
timing_netN.0_*.txt то же для секундомера
probe_noasm.txt проба на одном классе без дизассемблера
probe_asm.txt та же проба с дизассемблером
bench\ отчёты BenchmarkDotNet: csv, md, html, машинный код
JitFoldProof.csproj многоцелевой: net8.0, net9.0, net10.0
JitFoldProof.slnx
Program.cs точка входа, разбор аргументов
Subjects.cs все измеряемые методы
README.md
all.bat весь прогон одной командой
Benchmarks\
EscapeBench.cs раздел 1, escape-анализ
ConstantBench.cs раздел 2, сравнение с литералом
GenericBench.cs раздел 3, проверки типа
ClearBench.cs раздел 4, очистка списка
TierBench.cs раздел 5, поддержка векторов
Types\
BenchmarkConfig.cs три рантайма, дизассемблер, колонки и журнал
Payloads.cs наборы данных
Shapes.cs значимые типы со ссылкой и без, тип вне проверок
Diagnostics\
Checks.cs сверка, код возврата
Counters.cs обращения к полезной работе
Alloc.cs выделено байт без BenchmarkDotNet
IlSize.cs размер тела метода в IL
Warmup.cs уровни компиляции
Timing.cs секундомер, три прохода
Results\
Comp_1 .. Comp_4\ выгрузки прогона, по папке на машину
Comp_N\bench\ отчёты BenchmarkDotNet
- Обращение, которым ввели IsKnownConstant
- Предложение вывести IsKnownConstant в публичное API
- Вычисление проверок поддержки векторов
- Векторы и интринсики в устройстве рантайма
- Настройки рантайма
- Настройки сборщика мусора
Числа в этом файле взяты только из отчётов. Отношения посчитаны из исходных значений, а не из округлённых.
Результаты .NET 10 не переносятся на предыдущие версии. Где числа на .NET 8 и .NET 9 другие, приведены оба.
Ссылки на исходники .NET даны на тег v10.0.0, коммит зафиксирован: main
уедет, и номера строк перестанут совпадать.