НейроАгент

Почему одинаковые делегаты работают по-разному в C#

Разберемся в причинах 10-кратной разницы производительности между идентичными делегатами в C#. Узнайте о JIT-оптимизации, кэшировании и инлайнинге.

Вопрос

Почему одинаковые делегаты работают по-разному, в 10 раз разница в скорости?

Что за ерунда наблюдается. В примере создаются 2 одинаковых делегата (если только я не нашел, в чем дело, проверяя 1000 комбинаций причин), тот, кто первый запустится, будет в 10 раз быстрее. Почему так происходит, неожиданно поймал такое.

Вот для простоты пример, замеряю работу функции, создал расширение For для выполнения N раз функции для элементов массивов, без него эффект не наблюдается, если допустим цикл внутрь m(()=>{for…}); вставить.

Немного подебажил, и вроде заметил, что второй в порядке вызова метод вообще не инлайнится, и там прямо стандартный метод вместо хотя бы 2 инструкций “lea lea eax, [rcx + rdx]; ret” там 10-20 как в debug режиме.

csharp
var arr = Enumerable.Range(0, 1_000_000).Select(i => (X: (uint)i, Y: 1u)).ToArray(); // от n не зависит, (кортеж - артефакт поиска причины)
// одинаковые делегаты, второй в порядке вызова будет до 10-100 раз медленнее!!!!
var del = () => arr.For((a) => test1(a.Item1, a.Item2));
var del2 = () => arr.For((a) => test1(a.Item1, a.Item2));
m(del);    // зависит от порядка вызова  
m(del2);

// какая-то сложная функция до 50 асемблерных строк всяких инструкций, эффект наблюдается где-то
static uint test1(uint i, uint j)
{
    return i + j; 
}

public static class ArrayExtensions
{
    public static void For<T>(this T[] arr, Action<T> func)
    {
        int len = arr.Length; 
        foreach(var i in arr)
        {
            func(i);
        }
    }
}

public static void m(Action action, Action load = null)
{
    Stopwatch sw = Stopwatch.StartNew();
    load?.Invoke();
    action();  
    load?.Invoke();
    
    sw.Start();
    action();
    sw.Stop();
    int n = (int)(5_000 / (sw.Elapsed.TotalMilliseconds + 0.001f));
    if (n == 0)
        n = 1;
    List<double> times = new List<double>();
    for (int i = 0; i < n; i++)
    {
        load?.Invoke();
        sw.Restart();
        action();
        sw.Stop();
        times.Add(sw.Elapsed.TotalMilliseconds);
    }

    double sum = times.Sum();
    double average = sum / n;
    double err = Math.Sqrt(times.Aggregate(0.0, (acc, e) => acc += (e - average) * (e - average)) / (n - 1));
    Console.WriteLine($"Test func: {action.Method.Name}");
    Console.WriteLine($"Count invoke: {n}");
    Console.WriteLine("Standard deviation: {0,20:f4} ms", err);
    Console.WriteLine("Max:                {0,20:f4} ms", times.Max());
    Console.WriteLine("Min:                {0,20:f4} ms", times.Min());
    var c1 = Console.ForegroundColor;
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine("Mean time         : {0,20:f4} ms", average);
    Console.ForegroundColor = c1;
    Console.WriteLine();
}

Результаты тестирования:

Test func: b__1
Count invoke: 5803
Standard deviation:               0.0146 ms
Max:                              0.3772 ms
Min:                              0.0362 ms
Mean time         :               0.0509 ms

Test func: b__1
Count invoke: 55555
Standard deviation:               0.0091 ms
Max:                              0.4715 ms
Min:                              0.0355 ms
Mean time         :               0.0420 ms

Test func: b__2
Count invoke: 3902
Standard deviation:               0.0589 ms
Max:                              0.9081 ms
Min:                              0.3880 ms
Mean               :               0.4358 ms

Test func: b__2
Count invoke: 6124
Standard deviation:               0.0519 ms
Max:                              0.7931 ms
Min:                              0.3880 ms
Mean              :               0.4263 ms

В чем причина такого поведения и как это исправить?

НейроАгент

Разница в производительности между идентичными делегатами в C# может достигать 10-кратной из-за особенностей работы JIT-компилятора .NET и кэширования делегатов. Это поведение связано с механикой оптимизации во время выполнения, а не с логикой вашего кода.

Содержание

Основные причины производительности

Ваше наблюдение объясняется несколькими ключевыми факторами работы JIT-компилятора .NET:

  1. Кэширование делегатов: JIT-компилятор кэширует созданные делегаты, но этот механизм зависит от порядка вызовов. Как указано в исследованиях, “кэширование делегата созданного lambda-выражением зависит от компилятора”.

  2. Первый вызов vs последующие: Первый вызов делегата может быть значительно медленнее из-за затрат на JIT-компиляцию и оптимизацию этого конкретного пути выполнения.

  3. Отсутствие инлайнинга: Делегаты практически никогда не инлайнятся, даже если JIT-оптимизатор мог бы определить целевой метод. Исследования показывают, что “callbacks через делегат никогда не инлайнятся”.

Роль JIT-компилятора и кэширования

JIT-компилятор .NET применяет несколько стратегий оптимизации, которые напрямую влияют на производительность делегатов:

Кэширование делегатов: According to the research, “C# compiler does optimize this by caching the delegate and re-using it for both calls”. Однако этот механизм работает не всегда предсказуемо.

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

Факторы affecting кэширование:

  • Порядок создания и вызова делегатов
  • Контекст выполнения (метод, где создан делегат)
  • Наличие оптимизаций первого вызова

Инлайнинг методов и делегаты

Одна из главных причин производительности разницы - отсутствие инлайнинга для делегатов:

csharp
// Прямой вызов метода (может быть инлайнен)
uint result = test1(i, j);

// Вызов через делегат (никогда не инлайнен)
var action = (uint x, uint y) => test1(x, y);
uint result = action(i, j);

Как показывают исследования, “callbacks through a delegate are never inlined even if the jitter optimizer could deduce what the delegate’s target method might be”. Это означает, что вместо 2-3 инструкций сборки у вас может быть 10-20 инструкций при вызове через делегат.

Сравнение производительности:

  • Прямой метод вызов: ~324 мс (в тесте)
  • Вызов через делегат: ~1904 мс (в тесте)
  • Виртуальный вызов: ~2714 мс (в тесте)

Порядок выполнения и оптимизация

Ваше наблюдение о порядке вызовов подтверждает, что JIT-компилятор применяет оптимизации на основе истории выполнения:

csharp
var del = () => arr.For((a) => test1(a.Item1, a.Item2));
var del2 = () => arr.For((a) => test1(a.Item1, a.Item2));

m(del);    // Первый вызов - кэшируется, оптимизируется
m(del2);   // Второй вызов - может использовать кэш, но не всегда

Почему порядок важен:

  1. JIT-компилятор отслеживает паттерны вызовов
  2. После первого вызова метод может быть помечен для оптимизации
  3. Последующие вызовы в том же контексте могут использовать оптимизированный код
  4. Разные делегаты могут рассматриваться как разные контексты, даже если они идентичны

Решения и лучшие практики

Для достижения стабильной производительности делегатов рекомендуется:

1. Избегайте повторного создания одинаковых делегатов

csharp
// Плохо: создание новых делегатов в каждой итерации
for (int i = 0; i < count; i++)
{
    var action = () => Process(i);
    action();
}

// Хорошо: создайте один делегат и переиспользуйте его
var action = () => { /* логика */ };
for (int i = 0; i < count; i++)
{
    action();
}

2. Используйте прямые вызовы методов там, где это возможно

csharp
// Вместо делегатов для простых операций
uint result = i + j;  // Быстро
// Вместо:
uint result = addDelegate(i, j);  // Медленно

3. Предварительная “прогревка” JIT

csharp
// Выполните делегат один раз перед основным циклом
// для "прогревки" JIT-компилятора
del();

4. Используйте кэширование делегатов

csharp
private static readonly Func<uint, uint, uint> CachedAdd = 
    (i, j) => i + j;

Детальный разбор вашего примера

Ваш код демонстрирует классический случай, где JIT-компилятор применяет разные стратегии оптимизации:

Проблема в методе For

csharp
public static void For<T>(this T[] arr, Action<T> func)
{
    int len = arr.Length; 
    foreach(var i in arr)
    {
        func(i);  // <-- Здесь происходит вызов через делегат
    }
}

Каждый вызов func(i) - это вызов через делегат, который не инлайнится. При 1,000,000 итерацих это создает огромные накладные расходы.

Анализ результатов

Ваши результаты показывают:

  • Первый делегат: ~0.05 мс (быстро)
  • Второй делегат: ~0.43 мс (в 8.6 раз медленнее)

Это показывает, что JIT-компилятор применил оптимизации к первому делегату после первого вызова, но второй делегат рассматривается как отдельный контекст.

Заключение

10-кратная разница в производительности между идентичными делегатами возникает из-за:

  1. Кэширования JIT: первый вызов делегата кэшируется и оптимизируется
  2. Отсутствия инлайнинга: делегаты никогда не инлайнятся, создавая дополнительные инструкции
  3. Контекста выполнения: JIT рассматривает разные делегаты как разные контексты
  4. Порядка вызовов: ранние вызовы получают преимущества оптимизации

Рекомендации для исправления:

  • Создавайте делегаты один раз и переиспользуйте их
  • Избегайте создания делегатов внутри горячих циклов
  • Используйте прямые вызовы методов для простых операций
  • Реализуйте кэширование делегатов для часто используемых операций
  • Применяйте “прогревку” JIT перед основными вычислениями

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

Источники

  1. Can the C# compiler or JIT optimize away a method call in a lambda expression? – Stack Overflow
  2. C# delegate compiler optimisation – Stack Overflow
  3. Are deterministically unchangable Actions, and Funcs Inlined by the JIT? – Stack Overflow
  4. What optimization hints can I give to the compiler/JIT? – Stack Overflow
  5. Compiled C# Lambda Expressions Performance – Stack Overflow