Картинка с сайта mobillegends.net по запросу inline functions
Картинка с сайта mobillegends.net по запросу inline functions

Изучая производительность методов в различных коллекциях, я наткнулся на интересный факт: там, где нужно выбросить 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 на достаточно простом примере кода:

JIT в .NET принимает решение об inline метода
JIT в .NET принимает решение об inline метода

Также, компилятор руководствуется следующими правилами:

  1. Если метод никогда не возвращает значение (например, просто делает throw new ...) то такие методы автоматически помечаются как throw-helpers и не инлайнятся. Это такой способ замести сложный кодген от throw new под ковер и ублажить инлайнер.

  2. Виртуальные методы нельзя заинлайнить, так как нельзя заинлайнить то, о чем нет информации на этапе компиляции, хотя если тип или метод sealed то почему бы и нет.

  3. Если мы используем [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 тех методов, где это действительно важно.

Выводы

  1. Создайте статический класс а-ля Errors для выброса Exception'ов. Это стандартизирует выброс ошибок и сделает код чище.

  2. Методы класса Errors могут возвращать объектное представление сформированного Exception, но лучше, чтобы throw происходил прямо в методе этого класса. Введя подобную практику при написании кода, можно расширить возможности JIT'a по инлайну.

  3. Выброс Exception в критичном месте кода, где мы надеемся на inline - плохая идея, которая мешает JIT'у заинлайнить метод.

  4. Не надо баловаться с MethodImplAttribute, если вы не понимаете, как это работает и на что может повлиять. Используйте aggressive inline только тогда, когда вы имеете подтверждение (benchmark) того, что это положительно скажется на работе приложения.

P.S.: Начал писать в телегу про производительность. Заглядывайте, если интересно.

Комментарии (11)


  1. awfun
    00.00.0000 00:00

    Необходимость в типизации исключений, как мне кажется, опирается на различение сценариев их обработки. Многократно видел в проектах избыточно используемые классы исключений, которые имеют единый сценарий обработки. В текущем проекте multi-catch блок используется в единственном месте, реализует различную логику при ошибке авторизации и других типах исключений.

    Использование единого реестра кастомных исключений (если они нужны) выглядит интересным решением.


    1. teoadal Автор
      00.00.0000 00:00
      +1

      Да, единый реестр исключений это не только более эффективно по производительности, но и более красиво.

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


  1. Arm79
    00.00.0000 00:00
    +1

    У меня только один вопрос-просьба: в каких реальных, а не синтетических кейсах затраты на throw оказались настолько критичными, что потребовалась оптимизация? Приведите пример, пожалуйста


    1. teoadal Автор
      00.00.0000 00:00
      +6

      Пример: написание тиражируемых библиотек. Например, класс List<T> в базовой библиотеке .NET. Или, например, RavenDB.


      1. michael_v
        00.00.0000 00:00
        +1

        а зачем выбрасывать исключение из статического метода, тем самым нарушая анализ точек выхода в компилятре/IDE (см. пример кода по ссылке RavenDB где return после вызова метода Throw..)? почему бы не вернуть исключение (интерфейс Throwable), а ключевое слово throw оставить на месте вызова статического метода? тем самым сделав эти методы статическими методами-фабриками для исключений.


        1. teoadal Автор
          00.00.0000 00:00
          +1

          Цель подобных трюков как раз в том, чтобы обмануть компилятор и попытаться заинлайнить метод. Вообще, все микрооптимизации не про красивый код, а про то, чтоб добиться производительности.

          Однако, в данном конкретном случае я в версию инлайна верю слабо. Скорее тут это сделано для единообразия кода.


        1. Mingun
          00.00.0000 00:00
          +1

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


  1. Leo_Eldorado
    00.00.0000 00:00
    +1

    Привет! Спасибо за интересную и познавательную статью. Всем, конечно хороши методы-хелперы выбрасывающие исключения, но у них есть один недостаток, в языке C# нельзя указать, что метод выбрасывает исключение, т.о. при использовании методов-хелперов, после них, далее по коду появляются предупреждения о например, использовании неинициализированных переменных или другие предупреждения, которых бы не было при явном использовании throw. Вопрос: а существует ли какой-нибудь атрибут в том же Решарпере, например, который будучи применен к методу-хелперу, "успокоил" бы компилятор, что бы он "думал", что этом обычный выброс исключения и генерировал бы описанные выше предупреждения?


    1. teoadal Автор
      00.00.0000 00:00

      Да, знакомая ситуация. И, увы, нет, я не знаю никаких способов это победить. Приходится ставить восклицательные знаки там, где я точно знаю, что не null.


    1. Mingun
      00.00.0000 00:00
      +1

      Начиная с .Net Core 3.0 появились атрибуты DoesNotReturn и DoesNotReturnIf, правда, к сожалению, они влияют только на nullable-анализ. Так что делать return после вызова методов всё равно нужно…


    1. lam0x86
      00.00.0000 00:00

      На сколько понял, вот тут описано, как такое можно реализовать. (Сам не пробовал)

      https://www.jetbrains.com/help/resharper/Contract_Annotations.html#syntax