Inline и throw
Picture from mobillegends.net about inline functions
Изучая производительность методов в различных коллекциях, я наткнулся на интересный факт: там, где нужно выбросить Exception, программисты дёргают метод в статическом классе, в котором и происходит throw. Поначалу я думал, что это просто удобно — иметь все ошибки в одном месте и там следить за их единообразием. Это да, это действительно удобно.
Вторая проблема — inline. Дело в том, что JIT по-умолчанию старается не инлайнить методы с throw, чтобы избежать сложной обвязки вокруг выброса исключения. Анализируя метод, JIT собирает некие метрики, из которого получается число. Если число превышает некий порог, то метод заинлайнен не будет. В случае с throw, это число сразу превышает порог.
Более глубоко о том, что такое inline методов, можно, например, прочитать вот тут. Узнать, чем руководствуется JIT при inline’e методов можно, например, вот тут и вот тут (раньше статья была на Хабре, теперь исчезла). Мы же сразу перейдём к benchmark’у.
Проверяем 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 (_min < 0 || 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).
Benchmark: inline method in C#
Глядя на этот 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.: Начал писать в телегу про производительность. Заглядывайте, если интересно.