alexeyfv

Опубликовано

- 2 мин чтения

Проблемы с производительностью при передаче метода в C#

C# Производительность
img of Проблемы с производительностью при передаче метода в C#

На Хабре есть статья о проблемах производительности при передаче метода в качестве параметра в C#. Автор показал, что передача экземплярного метода в цикле for может ухудшать производительность и увеличивать расход памяти из-за лишних аллокаций в куче. В этой короткой заметке я хочу повторить оригинальный бенчмарк и сравнить, что изменилось после выхода .NET 7.

Бенчмарк

В оригинальной статье автор сравнивал только два варианта объявления метода: заранее заданный делегат и экземплярный метод. Я решил проверить и другие способы. Вот полный список:

  • заранее заданный делегат;
  • лямбда-выражение;
  • лямбда-выражение, вызывающее экземплярный метод;
  • лямбда-выражение, вызывающее статический метод;
  • статический анонимный метод;
  • статический анонимный метод, вызывающий статический метод;
  • анонимный метод;
  • анонимный метод, вызывающий экземплярный метод;
  • анонимный метод, вызывающий статический метод;
  • экземплярный метод;
  • статический метод.

Для тестирования я использовал библиотеку BenchmarkDotNet. Полный код тестов доступен здесь.

Результаты

Результаты бенчмарка на диаграмме ниже. Как видно, в .NET 7 лучше работают все варианты со статическими методами (кроме анонимного метода, вызывающего статический).

Диаграмма сравнения производительности передачи метода в .NET 6 и .NET 7

Результаты бенчмарка

Чтобы понять, почему так происходит, нужно заглянуть в IL-код. Все способы передачи метода можно разделить на две группы:

  1. Которые создают новый объект на каждой итерации цикла for.
  2. Которые не создают новый объект на каждой итерации.

Например, рассмотрим вызов статического метода (4-я строка на диаграмме):

   for (int i = 0; i < n; i++) {
    CallAdd(StaticAdd, i, i);
}

В .NET 6 после компиляции это будет выглядеть так:

   for (int i = 0; i < 10000; i++) {
    // На каждой итерации создаётся новый экземпляр Func
    CallAdd(new Func<int, int, int>(
      (object) null,
      __methodptr(StaticAdd)
    ), i, i);
}

А в .NET 7 ситуация другая:

   for (int i = 0; i < 10000; ++i) {
    CallAdd(
        // Новый экземпляр Func создаётся только один раз
        BenchmarkableClass.<>O.<1>__StaticAdd ?? (
            BenchmarkableClass.<>O.<1>__StaticAdd = new Func<int, int, int>(
                (object) null,
                __methodptr(StaticAdd)
                )), i, i
      );
}

Компилятор создаёт скрытый статический класс <>O с публичным полем <1>__StaticAdd типа Func<int, int, int>, которое инициализируется только один раз при первой итерации. То же самое происходит и для других вариантов со статическими методами.

Неоптимальный код .NET 6, который создаёт новые объекты на каждой итерации, вызывает лишние аллокации в куче. Из-за этого дополнительно запускается сборка мусора, что тоже влияет на производительность.

Выводы

Да, проблемы с производительностью всё ещё существуют, но платформа .NET стала лучше.

Если вы разрабатываете на .NET 6, лучше заранее создавать экземпляр делегата перед циклом for или использовать лямбда-выражение со статическим методом.

В .NET 7 вариантов больше. Можно использовать заранее созданный делегат, лямбда-выражение со статическим методом, напрямую передавать статический метод или использовать статический анонимный метод.