ESPNNFL Future Power Rankings: We stacked all 32 teams based on expectations for 2027 to 2029Daily MaverickIf SA keeps failing to implement plans, it’s time to rethink planningPunchBREAKING: Edo Assembly gets new speakerThe Jerusalem PostUS-Iran talks hit new impasse as MoU's 60-day deadline expires with no dealוואלהנתניהו נפגש עם קושנר ובכירי מועצת השלום; הפגישה נמשכת יותר משעתייםRTP Desporto12h30 Benfica sem margem para errar com Casa PiaBollywood HungamaEXCLUSIVE: Ram Gopal Varma’s Police Company to be made in two parts; shooting begins todayInquirerLPA may become storm in 2 days as habagat persists – PagasaTagesschauDie politische Risse hinter der Militärparade in WarschauGolem.dePhotovoltaik: Kommerzielle Perowskit-Solarzellen bestehen LangzeittestSportstarMohun Bagan vs Jamshedpur FC LIVE score: MBSG 1-1 JFC; Sahal scores in 2nd minute; Durand Cup 2026 quarterfinal updatesEgypt IndependentIran inflation “alarming” with basic food items a luxury for many, Iranian media says
The Daily Newsstand · Free, Always
Monday, August 17, 2026

Бенчмаркая try/finally: один finally — и метод в 6,45 раза медленнее

Translate

Уважаемые читатели, в этой статье я хочу рассказать о том, насколько медленнее работает метод, если обернуть его код в try/finally — и представить свои выводы.

Когда нет исключений, try/finally ничего не делает. Но JIT смотрит не на это: до .NET 10 он не подставлял код метода в место вызова, если внутри метода есть try/finally. Каждое обращение к такому методу означало переход по адресу и возврат обратно.

Сравниваются четыре варианта:

  • метод без try/finally;

  • метод с try/finally;

  • обход списка через foreach;

  • обход через MoveNext.

Будет 3 истории:

  • разница между двумя методами на .NET 8, .NET 9 и .NET 10;

  • замер без обращения к памяти;

  • переменная среды, при которой .NET 10 работает как .NET 8, и foreach, которого изменение не коснулось.

Замеры сделаны на четырёх машинах:

CPU

Ядра/потоки

Частота

ОС

№1

AMD Ryzen 9 5950X

16 / 32

3,4 ГГц, до 4,9 в турбо

Windows 10 1809

№2

Intel Core i9-10900KF

10 / 20

3,7 ГГц, до 5,3 в турбо

Windows 10 22H2

№3

2 × Intel Xeon Silver 4314

2×16 / 64

2,4 ГГц, до 3,4 в турбо

Windows Server 2022

№4

Intel Xeon W-2255

10 / 20

3,7 ГГц, до 4,5 в турбо

Windows Server 2022

BenchmarkDotNet 0.15.8, сборка Release. Патч-версии рантаймов на машинах различаются, поэтому сравнивать имеет смысл столбцы внутри одной машины. Замеры сняты на .NET 8, .NET 9 и .NET 10, машинный код — дополнительно на .NET 11.

История 1. Одна строка в finally

Оба метода выполняют одну и ту же работу. Разница только в том, где увеличивается счётчик:

// InliningEhProof, Subjects.cs

public static int ElementPlain(int[] data, int index)
{
    int value = data[index];

    Volatile.Write(ref _touched, _touched + 1);

    return value;
}

public static int ElementFinally(int[] data, int index)
{
    try
    {
        return data[index];
    }
    finally
    {
        Volatile.Write(ref _touched, _touched + 1);
    }
}

Счётчик добавлен затем, чтобы finally не был пустым: пустой finally JIT удаляет на любом рантайме, и оба метода становятся одинаковыми. Volatile.Write не позволяет вынести увеличение счётчика за пределы цикла — без этого JIT подставит код метода в место вызова, уберёт увеличение из цикла, и замер покажет разницу не между try/finally и его отсутствием, а между двумя разными объёмами работы.

Оба метода вызываются в цикле по массиву из третьего метода — на нём атрибут NoInlining (его и меряем). У первых двух атрибута нет, чтобы сразу понять, JIT подставит их код в место вызова или нет. Длина массива задаётся через [Params(4, 16, 64)] и равна числу вызовов за одну операцию.

Машина

Способ

.NET 8

.NET 9

.NET 10

№1 Ryzen 5950X

без try/finally

1,147

1,225

1,247

№1 Ryzen 5950X

с try/finally

7,401

6,534

1,306

№2 i9-10900KF

без try/finally

2,577

2,659

2,625

№2 i9-10900KF

с try/finally

6,238

6,170

2,997

№3 2×Xeon Silver

без try/finally

8,678

8,583

8,666

№3 2×Xeon Silver

с try/finally

10,051

10,977

9,348

№4 Xeon W-2255

без try/finally

3,581

3,224

3,156

№4 Xeon W-2255

с try/finally

9,995

10,400

3,483

Наносекунды на четыре вызова: на .NET 8 метод с try/finally медленнее в 1,16–6,45 раза, на .NET 10 разница сокращается до 1,05–1,14

На .NET 8 метод с try/finally медленнее в 1,16–6,45 раза, на .NET 9 — в 1,28–5,33, а на .NET 10 от этой разницы почти ничего не остаётся: от 1,05 до 1,14 на всех четырёх машинах. Нижние границы 1,16 и 1,28 — это 2 × Xeon Silver 4314, на трёх остальных машинах разница на .NET 8 составляет 2,42–6,45 раза.

На 2 × Xeon Silver 4314 разницы почти нет ни на одном рантайме: 1,16 на .NET 8 и 1,08 на .NET 10. Почему так — во второй истории.

Ограничение снято в .NET 10, что отмечено в документации: некоторые методы, имеющие семантику обработки исключений, в частности блоки с try-finally, также могут встраиваться. Изменение обсуждалось в dotnet/runtime#108900.

Снято оно не для всех блоков обработки исключений. Условие в JIT выглядит так:

// dotnet/runtime, src/coreclr/jit/fgbasic.cpp (сокращено)

if (compIsForInlining())
{
    const bool isFinallyFaultOrFilter =
        (clause.Flags & (CORINFO_EH_CLAUSE_FINALLY | CORINFO_EH_CLAUSE_FAULT |
                         CORINFO_EH_CLAUSE_FILTER)) != 0;

    if (!isFinallyFaultOrFilter)
    {
        JITDUMP("Inlinee EH clause %u is a catch; we can't inline these (yet)\n", XTnum);
        compInlineResult->NoteFatal(InlineObservation::CALLEE_HAS_EH);
        return;
    }
}

Пропускаются finally, fault и filter. Метод с catch внутри JIT не встраивает.

Разница между рантаймами может объясняться и другими изменениями. Для проверки в проекте есть ещё два метода с тем же кодом и атрибутом [MethodImpl(MethodImplOptions.NoInlining)]. Если JIT запретить подставлять код метода в место вызова, разницы между рантаймами быть не должно.

С этим атрибутом метод с try/finally показывает 7,377–10,074 наносекунды на .NET 8 и 5,941–9,012 на .NET 10. Отношение .NET 8 к .NET 10 здесь 1,03–1,24 раза, а без атрибута тот же метод даёт 1,08–5,67. Запрет на подстановку кода снимает и разницу между рантаймами.

То же самое видно в машинном коде. Вызывающий метод на .NET 8:

; InliningEhProof.Subjects:CallElementFinally(int[]):int (Tier1)
; Results/Comp_2/Disasm/disasm_net8.txt

G_M000_IG03:
       mov      rcx, rbx
       mov      edx, edi
       call     [InliningEhProof.Subjects:ElementFinally(int[],int):int]
       add      esi, eax               ; накопление суммы
       inc      edi
       cmp      ebp, edi
       jg       SHORT G_M000_IG03

; Total bytes of code 52

Он же на .NET 10:

; InliningEhProof.Subjects:CallElementFinally(int[]):int (Tier1)
; Results/Comp_2/Disasm/disasm_net10.txt

; 1 inlinees with PGO data; 0 single block inlinees; 0 inlinees without PGO data

G_M000_IG04:
       mov      r10d, dword ptr [rcx]  ; чтение элемента без проверки границ
       inc      dword ptr [r8]         ; увеличение счётчика из finally
       add      eax, r10d
       add      rcx, 4                 ; переход к следующему элементу
       dec      edx
       jne      SHORT G_M000_IG04

; Total bytes of code 51

Вызова нет, код второго метода находится в цикле. Результат одинаковый на всех четырёх машинах.

В листинге видно и второе изменение: проверка границ массива тоже пропала, адрес следующего элемента считается сложением, а не индексированием с проверкой. То есть в 6,45 раза входит и убранная проверка границ.

История 2. Замер без обращений к памяти

На 2 × Xeon Silver 4314 два процессора и 64 логических ядра. Счётчик, который в первой истории добавлен только ради непустого finally, занимает там больше времени, чем сам вызов. Метод без try/finally на этой машине — 8,678 наносекунды против 1,147 на Ryzen 5950X, хотя делает он всего четыре чтения из массива и четыре увеличения счётчика.

Поэтому в проекте есть ещё два метода. Код тот же, но в них нет обращений к памяти. Вместо счётчика стоит проверка, которая не срабатывает.

// InliningEhProof, Subjects.cs

public static int ElementGuardFinally(int[] data, int index)
{
    try
    {
        return data[index];
    }
    finally
    {
        if (index < 0)
        {
            throw new InvalidOperationException("Отрицательный индекс");
        }
    }
}

Блок обработки исключений в IL никуда не делся, а решение JIT принимает как раз по IL, до любых оптимизаций. Числа в таблице ниже с первой не сравниваются: это другой метод.

Машина

Способ

.NET 8

.NET 9

.NET 10

№1 Ryzen 5950X

без try/finally

1,303

1,575

1,257

№1 Ryzen 5950X

с try/finally

6,240

6,554

4,164

№2 i9-10900KF

без try/finally

1,646

1,137

1,686

№2 i9-10900KF

с try/finally

6,240

7,305

3,481

№3 2×Xeon Silver

без try/finally

4,234

4,191

4,266

№3 2×Xeon Silver

с try/finally

8,866

8,970

6,098

№4 Xeon W-2255

без try/finally

2,428

2,038

1,692

№4 Xeon W-2255

с try/finally

7,532

7,533

4,102

Наносекунды на четыре вызова, код без обращений к памяти: на .NET 10 метод с try/finally быстрее, чем на .NET 8, на всех четырёх машинах, включая 2 × Xeon Silver 4314 — 6,098 против 8,866

Теперь 2 × Xeon Silver 4314 показывает то же, что три остальные: 1,45 раза против 1,50, 1,79 и 1,84. В первой таблице разницу перекрывал счётчик.

Второй замер добавляет ещё один результат. Разница с методом без try/finally сокращается, но не пропадает: на Ryzen 5950X с 4,79 раза на .NET 8 до 3,31 на .NET 10, на 2 × Xeon Silver 4314 — с 2,09 до 1,43. JIT подставил код метода, вызова нет, а ветка с throw осталась.

История 3. Динамический профиль и foreach

Все предыдущие замеры сняты на настройках по умолчанию. Тот же прогон с DOTNET_TieredPGO=0:

Машина

.NET 8

.NET 10

.NET 10 без профиля

№1 Ryzen 5950X

7,401

1,306

7,497

№2 i9-10900KF

6,238

2,997

5,668

№3 2×Xeon Silver

10,051

9,348

11,145

№4 Xeon W-2255

9,995

3,483

9,398

Метод с try/finally, наносекунды на четыре вызова: без динамического профиля .NET 10 возвращается к уровню .NET 8

Результаты .NET 10 вернулись к уровню .NET 8. В машинном коде видно то же самое:

; InliningEhProof.Subjects:CallElementFinally(int[]):int (Tier1)
; Results/Comp_2/Disasm/disasm_nopgo_net10.txt

; No PGO data

G_M000_IG03:
       mov      rcx, rbx
       mov      edx, edi
       call     [InliningEhProof.Subjects:ElementFinally(int[],int):int]
       add      esi, eax
       inc      edi
       cmp      ebp, edi
       jg       SHORT G_M000_IG03

Без профиля JIT подставляет код одних методов и не подставляет других. Обычный метод он подставляет и здесь — 1,376 наносекунды против 1,247 на Ryzen 5950X. А метод с try/finally не трогает: без профиля JIT не знает, что вызов частый.

С .NET 8 динамический профиль включён по умолчанию. Если его отключить, .NET 10 возвращается к уровню .NET 8: разброс по машинам от 0,91 до 1,11 против прежних 1,08–5,67.

Блок обработки исключений появляется в IL и без try/finally. Компилятор превращает foreach в try/finally с вызовом Dispose у перечислителя, и метод, который обходит List<int>, попадает под то же ограничение:

; InliningEhProof.Subjects:CallListForeach(System.Collections.Generic.List`1[int]):int (Tier1)
; Results/Comp_2/Disasm/disasm_net10.txt

G_M000_IG03:
       mov      rcx, rbx
       call     [InliningEhProof.Subjects:ListForeach(System.Collections.Generic.List`1[int]):int]
       add      esi, eax
       dec      edi
       jne      SHORT G_M000_IG03

Листинги сняты и на .NET 11 — вызов остаётся на всех четырёх рантаймах и на всех четырёх машинах. А код обхода через MoveNext, где Dispose вызывается за пределами try, JIT подставляет везде.

Элементов

№1 Ryzen

№2 i9

№3 Xeon Silver

№4 Xeon W

4

×1,19

×1,48

×1,68

×1,58

16

×1,05

×1,02

×1,10

×1,02

64

×1,03

×1,03

×1,02

×1,01

Во сколько раз обход через foreach медленнее обхода через MoveNext на .NET 10: до ×1,68 на четырёх элементах, ×1,01–1,03 на 64

Причина в размере. Изменение в .NET 10 работает для методов в несколько строк, а метод с циклом внутри JIT не подставляет и на .NET 10. На списке из четырёх элементов разница заметна, на 64 она теряется — там больше времени уходит на перебор элементов.

Выводы

По цифрам:

  • из-за одного finally метод из двух строк медленнее в 1,16–6,45 раза на .NET 8 и в 1,28–5,33 на .NET 9 — не из-за самого блока, а потому что JIT не подставляет его код в место вызова;

  • на .NET 10 разница сокращается до 1,05–1,14: вызова в машинном коде нет, вместе с ним пропадает проверка границ массива;

  • если JIT запретить подставлять код метода, разница между .NET 8 и .NET 10 падает до 1,03–1,24 раза — вместо 1,08–5,67 без запрета;

  • ограничение снято для finally, fault и filter. JIT не встраивает метод с catch внутри;

  • на коде без обращений к памяти .NET 10 быстрее .NET 8 на всех четырёх машинах — в 1,45–1,84 раза. Разница с методом без try/finally сокращается с 2,09–4,79 до 1,43–3,31: вызова уже нет, а ветка с throw остаётся в коде;

  • с DOTNET_TieredPGO=0 .NET 10 возвращается к уровню .NET 8 на всех четырёх машинах — отношение от 0,91 до 1,11. Код метода без try/finally JIT подставляет и без профиля;

  • код метода с foreach JIT не подставляет ни на .NET 8, ни на .NET 9, ни на .NET 10, ни на .NET 11: он медленнее обхода через MoveNext в 1,19–1,68 раза на четырёх элементах и в 1,01–1,03 раза на 64.

Что делать на практике:

  • на .NET 8 и .NET 9 using, lock, foreach и явный try/finally не дают JIT подставить код метода, в котором они написаны. Если такой метод вызывается миллионы раз, их стоит перенести в вызывающий код;

  • на .NET 10 ограничение снято, поэтому переписывать смысла нет. Исключение — методы с catch: их JIT не подставляет даже на .NET 10;

  • в горячем методе, где весь код — это цикл по коллекции, foreach замените на MoveNext с Dispose за пределами try — тогда его код JIT подставит в место вызова. На коллекции из нескольких элементов выигрыш до 1,68 раза, на десятках его уже нет;

  • не отключайте динамический профиль: без него .NET 10 работает как .NET 8;

  • речь про единицы наносекунд на вызов, поэтому переписывайте только методы из нескольких строк на горячем пути.

Код из статьи

  • InliningEhProof — бенчмарки, прогоны на четырёх машинах и листинги машинного кода

Ссылки

Всем удачи и до новых встреч!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.