Skip to content

Repository files navigation

JitFoldProof

Замеры к статье «А чё, так можно было? 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. Журналы прогона в репозиторий не вошли: они по мегабайту и нужны только при разборе сбоя.

Результаты

1. Escape-анализ

Размер машинного кода и объём выделенной памяти. Совпало на всех четырёх машинах до байта.

Что измерялось .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.

2. Сравнение с литералом

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 больше, а машинного кода получается втрое меньше.

3. Двенадцать проверок типа

Один и тот же метод, 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.

4. Очистка списка

Миллион элементов, .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

Два значимых типа оказались по разные стороны: важно не «ссылочный тип против значимого», а наличие ссылок в самом типе. У первых двух строк стандартное отклонение сопоставимо со средним, различать их между собой нельзя — обе очистки оставляют массив как есть.

5. Проверка поддержки векторов

Сумма массива из 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

Ссылки

Оговорки

Числа в этом файле взяты только из отчётов. Отношения посчитаны из исходных значений, а не из округлённых.

Результаты .NET 10 не переносятся на предыдущие версии. Где числа на .NET 8 и .NET 9 другие, приведены оба.

Ссылки на исходники .NET даны на тег v10.0.0, коммит зафиксирован: main уедет, и номера строк перестанут совпадать.

About

Пять мест в исходниках .NET, где написанный код не попадает в машинный. Замеры на BenchmarkDotNet, .NET 8/9/10, четыре машины, со снятием машинного кода.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages