Изучая производительность методов в различных коллекциях, я наткнулся на интересный факт: там, где нужно выбросить Exception, программисты дёргают метод в статическом классе, в котором и происходит throw. Поначалу я думал, что это просто удобно — иметь все ошибки в одном месте и там следить за их единообразием. Это да, это действительно удобно. Но есть нюанс...
И этот нюанс называется инлайн методов (method inlining) — включение тела вызываемого метода в тело вызывающего метода. Это один из самых эффективных способов оптимизации, но, увы, очень капризный.
В контексте разговора про throw нужно иметь ввиду следующее: JIT по‑умолчанию старается не инлайнить методы с throw, чтобы избежать сложной обвязки вокруг выброса исключения. Анализируя метод, JIT собирает некие метрики, из которого получается число. Если число превышает некий порог, то метод заинлайнен не будет. В случае с throw, это число сразу превышает этот порог.
Что такое inline методов
Более глубоко о том, что такое inline методов, можно, например, прочитать вот тут. Узнать, чем руководствуется JIT при inline'e методов можно, например, вот тут и вот тут.
Ещё одна хорошая статья раньше была на Хабре, но исчезла. Её текст остался на всяких левых ресурсах, поэтому далее курсивом будут прямые вырезки и картинки из этой статьи.
Инлайнинг — одна из самых важных оптимизаций в компиляторах. Она не только убирает оверхед от вызова, но и открывает много возможностей для других оптимизаций, например, constant folding, dead code elimination. Более того, иногда инлайнинг приводит к уменьшению размера вызывающего метода. Я опросил несколько человек на предмет, знают ли они по каким правилам инлайнятся функции в C# и большинство ответили, что JIT смотрит на размер IL кода и инлайнит только маленькие функции размером, скажем, до 32 байт. И это правда, но только частично.
Большинство компиляторов используют "наблюдения и эвристики" (метрики) для принятия решения об инлайнинге. RyuJIT имеет положительные и отрицательные метрики. Положительные увеличивают коэффициент выгоды (benefit multiplier). Чем больше коэффициент - тем больше кода мы можем заинлайнить. Отрицательные эвристики наоборот - понижают его или вообще могут запретить инлайнинг. Давайте посмотрим какие наблюдения сделал RyuJIT на достаточно простом примере кода:
Также, компилятор руководствуется следующими правилами:
Если метод никогда не возвращает значение (например, просто делает
throw new
...) то такие методы автоматически помечаются как throw-helpers и не инлайнятся. Это такой способ замести сложный кодген отthrow new
под ковер и ублажить инлайнер.Виртуальные методы нельзя заинлайнить, так как нельзя заинлайнить то, о чем нет информации на этапе компиляции, хотя если тип или метод sealed то почему бы и нет.
Если мы используем
[MethodImpl(MethodImplOptions.AggressiveInlining)]
, то в этом случае мы рекомендуем компилятору заинлайнить метод. Однако, тут надо быть предельно осторожными, так как, возможно, мы оптимизируем один случай и ухудшаем все остальные. Например, улучшаем случай константных аргументов, но ухудшаем метрику по размеру сгенерированного кода.
Теперь, вооружившись знаниями о принятии решения по инлайнингу, мы можем перейти к тестированию производительности.
Проверяем inline
Ситуация простая: у нас есть класс, в котором несколько абсолютно одинаковых методов. Ну, почти.
public sealed class InliningService {
private readonly int _min;
...
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int AggressiveInline(int a, int b) {
if (a < _min || b < _min) throw new InvalidOperationException();
return a + b;
}
public int AutoInline(int a, int b) {
if (a < _min || b < _min) Errors.InvalidOperation();
return a + b;
}
public int WithoutInline(int a, int b) {
if (a < _min || b < _min) throw new InvalidOperationException();
return a + b;
}
В одном случае мы делаем throw прямо в методе (Without Inline), в другом случае мы просим всё-таки заинлайнить такой метод (Aggressive Inline), а в третьем случае мы полагаемся на JIT и вызов статического метода, в котором выбрасывается ошибка (Auto Inline).
Method | Runtime | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|
AggressiveInline | .NET 6.0 | 50.10 ns | 0.327 ns | 0.273 ns | 1.02 |
AutoInline | .NET 6.0 | 49.05 ns | 0.257 ns | 0.228 ns | 1.00 |
WithoutInline | .NET 6.0 | 194.35 ns | 3.067 ns | 2.561 ns | 3.96 |
AggressiveInline | .NET Core 3.1 | 49.62 ns | 0.453 ns | 0.424 ns | 1.02 |
AutoInline | .NET Core 3.1 | 48.89 ns | 0.113 ns | 0.100 ns | 1.00 |
WithoutInline | .NET Core 3.1 | 150.60 ns | 1.660 ns | 1.386 ns | 3.08 |
AggressiveInline | .NET Framework 4.8 | 54.83 ns | 0.643 ns | 0.602 ns | 1.05 |
AutoInline | .NET Framework 4.8 | 52.24 ns | 0.588 ns | 0.550 ns | 1.00 |
WithoutInline | .NET Framework 4.8 | 171.66 ns | 2.474 ns | 2.314 ns | 3.29 |
Глядя на этот benchmark, можно сделать несколько наблюдений.
Во‑первых, скорость работы во всех трёх популярных версиях .NET примерно одинаковая. Странное увеличение времени работы метода без inline'a на .NET Core 3.1 я предлагаю списать на погрешность. Тем более, что размер IL‑кода (Code Size) во всех трёх framework'ах одинаковый.
Во‑вторых, скорость работы метода, который не был заинлайнен, предсказуемо ниже, чем версия, где JIT принял решение сделать inline. Причём почти в два раза. Это позволяет нам говорить о том, что нужно прятать throw в статический класс там, где выброс Exception будет дорогим и мы надеемся на inline.
В‑третьих, колонка Code Size достаточно чётко намекает нам на то, что aggressive inline метода с throw в этом случае позволяет JIT сделать inline, но путём увеличения размера кода. По сравнению с AutoInline — разница драматичная. Подобный inline плох, поскольку повлиял бы на работу и возможность inline'a других методов, сделал бы невозможным inline тех методов, где это действительно важно.
Выводы
Создайте статический класс а-ля Errors для выброса Exception'ов. Это стандартизирует выброс ошибок и сделает код чище.
Методы класса Errors могут возвращать объектное представление сформированного Exception, но лучше, чтобы throw происходил прямо в методе этого класса. Введя подобную практику при написании кода, можно расширить возможности JIT'a по инлайну.
Выброс Exception в критичном месте кода, где мы надеемся на inline - плохая идея, которая мешает JIT'у заинлайнить метод.
Не надо баловаться с MethodImplAttribute, если вы не понимаете, как это работает и на что может повлиять. Используйте aggressive inline только тогда, когда вы имеете подтверждение (benchmark) того, что это положительно скажется на работе приложения.
P.S.: Начал писать в телегу про производительность. Заглядывайте, если интересно.