Уважаемые читатели, в этой статье я хочу рассказать о том, насколько медленнее работает метод, если обернуть его код в 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.cspublic 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.txtG_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 dataG_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.cspublic 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 dataG_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.txtG_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 — бенчмарки, прогоны на четырёх машинах и листинги машинного кода
Ссылки
Всем удачи и до новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1071238/