Замер того, насколько медленнее работает метод, если обернуть его код
в try/finally. До .NET 10 JIT не подставлял код метода в место вызова,
если в IL есть блок обработки исключений. Появляется он не только от
написанного явно try/finally, но и от foreach, куда компилятор
добавляет finally ради Dispose у перечислителя. В .NET 10 ограничение
снято — но работает это только с динамическим профилем.
Репозиторий к статье «Бенчмаркая try/finally: один finally — и метод в 6,45 раза медленнее».
Наносекунды на четыре вызова, [Params(4)], из Results/Comp_1..4/Pgo.
| Метод | .NET | №1 Ryzen 5950X | №2 i9-10900KF | №3 2×Xeon Silver | №4 Xeon W-2255 |
|---|---|---|---|---|---|
без try/finally |
8 | 1,147 | 2,577 | 8,678 | 3,581 |
без try/finally |
9 | 1,225 | 2,659 | 8,583 | 3,224 |
без try/finally |
10 | 1,247 | 2,625 | 8,666 | 3,156 |
с try/finally |
8 | 7,401 | 6,238 | 10,051 | 9,995 |
с try/finally |
9 | 6,534 | 6,170 | 10,977 | 10,400 |
с try/finally |
10 | 1,306 | 2,997 | 9,348 | 3,483 |
Отношение «с try/finally» к «без try/finally»:
| .NET | №1 | №2 | №3 | №4 |
|---|---|---|---|---|
| 8 | 6,45 | 2,42 | 1,16 | 2,79 |
| 9 | 5,33 | 2,32 | 1,28 | 3,23 |
| 10 | 1,05 | 1,14 | 1,08 | 1,10 |
На .NET 8 и .NET 9 один finally делает метод медленнее в 1,16–6,45
и 1,28–5,33 раза. Не сам блок: в машинном коде на этих рантаймах стоит
call, а на .NET 10 его нет — код вызываемого метода находится в цикле.
На .NET 10 разница сходит к 1,05–1,14. Вместе с вызовом пропадает и проверка границ массива: адрес следующего элемента считается сложением.
Ограничение снято не для всего. Условие в fgbasic.cpp пропускает
finally, fault и filter; метод с catch внутри JIT не подставляет.
Разницу создаёт именно подстановка кода. Методы …Blocked с атрибутом
NoInlining дают 7,377–10,074 наносекунды на .NET 8 и 5,941–9,012 на .NET 10:
отношение 1,03–1,24 против 1,08–5,67 у тех же методов без атрибута.
Счётчик может перекрыть разницу. На 2 × Xeon Silver 4314 запись
в статическое поле занимает больше времени, чем вызов, и первый замер
показывает всего 1,16. Методы Guard_* без обращений к памяти показывают
на той же машине ускорение в 1,45 раза, а на остальных — 1,50, 1,79 и 1,84.
Разница с методом без try/finally при этом сокращается с 2,09–4,79
до 1,43–3,31: вызова уже нет, а ветка с throw остаётся в коде.
Всё держится на динамическом профиле. С DOTNET_TieredPGO=0 .NET 10
возвращается к уровню .NET 8 — отношение от 0,91 до 1,11, а в машинном коде
call стоит на месте. Код метода без try/finally JIT подставляет и без
профиля.
foreach под ограничением остался. call на ListForeach стоит
на .NET 8, .NET 9, .NET 10 и .NET 11, на всех четырёх машинах. Обход того же
списка через MoveNext, где Dispose вызывается за пределами try,
JIT подставляет везде. На четырёх элементах foreach медленнее в 1,19–1,68
раза, на 64 — в 1,01–1,03.
Нужны рантаймы 8, 9 и 10. Если на машине стоит SDK 11, проект сам добавит
цель net11.0 и снимет отчёты ещё и на нём — global.json для этого класть
не надо.
all.bat
Отдельные шаги:
dotnet run -c Release -f net10.0 все замеры
dotnet run -c Release -f net10.0 -- checks сверка ответов
dotnet run -c Release -f net10.0 -- timing замер вне BenchmarkDotNet
Отчёты складываются в BenchmarkDotNet.Artifacts\results, прогон без профиля —
в BenchmarkDotNet.Artifacts.NoPgo\results, машинный код — в Disasm,
отчёты checks и timing — в корень проекта. Оттуда всё переносится
в Results/Comp_N.
Двенадцать методов, три класса замеров.
Element_Finally— чтение элемента массива, счётчик увеличивается вfinally;Element_Plain— тот же код, счётчик увеличивается обычной строкой;Guard_Finally/Guard_Plain— то же самое без обращений к памяти: вместо счётчика проверка, которая не срабатывает;Foreach_Compiler— обход списка черезforeach,try/finallyдобавляет компилятор;Foreach_Manual— обход того же списка черезMoveNext,Disposeвнеtry;- шесть методов
…Blocked— тот же код с[MethodImpl(MethodImplOptions.NoInlining)]на вызываемом методе.
У вызываемых методов атрибута нет — по ним и проверяется, подставит JIT
их код в место вызова или нет. NoInlining стоит на вызывающих Call…:
их меряет BenchmarkDotNet и их машинный код снимает Disasm.
Что закрыто замером, а не словами:
- Меряется подстановка кода, а не стоимость
try/finally. Методы…Blockedповторяют тот же код с запретом: если причина в подстановке, при запрете разницы между рантаймами быть не должно. Отношение падает с 1,08–5,67 до 1,03–1,24. finallyне пустой. В нём увеличиваетсяSubjects.Touched; пустойfinallyJIT удаляет на любом рантайме. Значение счётчика печатает отчётchecks.- Счётчик не уезжает из цикла. Запись идёт через
Volatile.Write, вынести её за цикл JIT не может. - Не артефакт BenchmarkDotNet. Тот же результат снимается на
Stopwatchотчётомtiming, с прогревом до Tier1. - Не заслуга профиля. Отдельный прогон с
DOTNET_TieredPGO=0лежит рядом, машинный код под ним снят отдельно. - Не счётчик перекрывает разницу. Методы
Guard_*меряют то же самое без единого обращения к памяти. - Размер не подобран. Параметр
Elements— 4, 16 и 64. - Ответы сходятся.
GlobalSetupкаждого класса и отчётchecksсверяют все двенадцать методов с ожидаемой суммой и останавливают прогон при расхождении.
Две оговорки, которые идут и в статью.
Подстановка кода снимает не только вызов: после неё JIT видит код целиком
и может убрать проверку границ массива. Поэтому разница между
Element_Finally на .NET 8 и на .NET 10 включает и её. Что именно пропало,
видно в машинном коде.
NoInlining у методов …Blocked запрещает не только подстановку, поэтому
разница с ними — оценка сверху.
InliningEhProof/
InliningEhProof.csproj net8.0;net9.0;net10.0 (+net11.0 при SDK 11)
InliningEhProof.slnx
Program.cs точка входа, BenchmarkSwitcher и два отчёта
Subjects.cs все измеряемые методы
all.bat весь прогон
README.md
Benchmarks/
ElementEhBench.cs явный try/finally
GuardEhBench.cs то же без обращений к памяти
ForeachEhBench.cs try/finally от компилятора
Diagnostics/
Diagnostic.cs сверка ответов и замер вне BenchmarkDotNet
Disasm/
Disasm.csproj net8.0;net9.0;net10.0 (+net11.0 при SDK 11)
Program.cs прогрев вызывающих методов до Tier1
Results/
Comp_1/ №1 AMD Ryzen 9 5950X, Windows 10 1809
Comp_2/ №2 Intel Core i9-10900KF, Windows 10 22H2
Comp_3/ №3 2 x Intel Xeon Silver 4314, Windows Server 2022
Comp_4/ №4 Intel Xeon W-2255, Windows Server 2022
Pgo/ отчёты BenchmarkDotNet: csv, md, html
NoPgo/ то же с DOTNET_TieredPGO=0
Disasm/ машинный код: net8, net9, net10, net11, nopgo
checks_netN.txt сверка ответов
timing_netN.txt замер вне BenchmarkDotNet
Машинный код из статьи — Results/Comp_2/Disasm/.