Skip to content

Repository files navigation

InliningEhProof

Замер того, насколько медленнее работает метод, если обернуть его код в 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; пустой finally JIT удаляет на любом рантайме. Значение счётчика печатает отчёт 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/.


Ссылки

About

Замер того, насколько медленнее работает метод с try/finally на .NET 8, 9 и 10: четыре машины, машинный код, прогон без динамического профиля

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages