Почему одинаковые делегаты работают по-разному, в 10 раз разница в скорости?
Что за ерунда наблюдается. В примере создаются 2 одинаковых делегата (если только я не нашел, в чем дело, проверяя 1000 комбинаций причин), тот, кто первый запустится, будет в 10 раз быстрее. Почему так происходит, неожиданно поймал такое.
Вот для простоты пример, замеряю работу функции, создал расширение For для выполнения N раз функции для элементов массивов, без него эффект не наблюдается, если допустим цикл внутрь m(()=>{for…}); вставить.
Немного подебажил, и вроде заметил, что второй в порядке вызова метод вообще не инлайнится, и там прямо стандартный метод вместо хотя бы 2 инструкций “lea lea eax, [rcx + rdx]; ret” там 10-20 как в debug режиме.
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-компилятора и кэширования
- Инлайнинг методов и делегаты
- Порядок выполнения и оптимизация
- Решения и лучшие практики
- Детальный разбор вашего примера
- Заключение
Основные причины производительности
Ваше наблюдение объясняется несколькими ключевыми факторами работы JIT-компилятора .NET:
-
Кэширование делегатов: JIT-компилятор кэширует созданные делегаты, но этот механизм зависит от порядка вызовов. Как указано в исследованиях, “кэширование делегата созданного lambda-выражением зависит от компилятора”.
-
Первый вызов vs последующие: Первый вызов делегата может быть значительно медленнее из-за затрат на JIT-компиляцию и оптимизацию этого конкретного пути выполнения.
-
Отсутствие инлайнинга: Делегаты практически никогда не инлайнятся, даже если 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 кэширование:
- Порядок создания и вызова делегатов
- Контекст выполнения (метод, где создан делегат)
- Наличие оптимизаций первого вызова
Инлайнинг методов и делегаты
Одна из главных причин производительности разницы - отсутствие инлайнинга для делегатов:
// Прямой вызов метода (может быть инлайнен)
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-компилятор применяет оптимизации на основе истории выполнения:
var del = () => arr.For((a) => test1(a.Item1, a.Item2));
var del2 = () => arr.For((a) => test1(a.Item1, a.Item2));
m(del); // Первый вызов - кэшируется, оптимизируется
m(del2); // Второй вызов - может использовать кэш, но не всегда
Почему порядок важен:
- JIT-компилятор отслеживает паттерны вызовов
- После первого вызова метод может быть помечен для оптимизации
- Последующие вызовы в том же контексте могут использовать оптимизированный код
- Разные делегаты могут рассматриваться как разные контексты, даже если они идентичны
Решения и лучшие практики
Для достижения стабильной производительности делегатов рекомендуется:
1. Избегайте повторного создания одинаковых делегатов
// Плохо: создание новых делегатов в каждой итерации
for (int i = 0; i < count; i++)
{
var action = () => Process(i);
action();
}
// Хорошо: создайте один делегат и переиспользуйте его
var action = () => { /* логика */ };
for (int i = 0; i < count; i++)
{
action();
}
2. Используйте прямые вызовы методов там, где это возможно
// Вместо делегатов для простых операций
uint result = i + j; // Быстро
// Вместо:
uint result = addDelegate(i, j); // Медленно
3. Предварительная “прогревка” JIT
// Выполните делегат один раз перед основным циклом
// для "прогревки" JIT-компилятора
del();
4. Используйте кэширование делегатов
private static readonly Func<uint, uint, uint> CachedAdd =
(i, j) => i + j;
Детальный разбор вашего примера
Ваш код демонстрирует классический случай, где JIT-компилятор применяет разные стратегии оптимизации:
Проблема в методе For
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-кратная разница в производительности между идентичными делегатами возникает из-за:
- Кэширования JIT: первый вызов делегата кэшируется и оптимизируется
- Отсутствия инлайнинга: делегаты никогда не инлайнятся, создавая дополнительные инструкции
- Контекста выполнения: JIT рассматривает разные делегаты как разные контексты
- Порядка вызовов: ранние вызовы получают преимущества оптимизации
Рекомендации для исправления:
- Создавайте делегаты один раз и переиспользуйте их
- Избегайте создания делегатов внутри горячих циклов
- Используйте прямые вызовы методов для простых операций
- Реализуйте кэширование делегатов для часто используемых операций
- Применяйте “прогревку” JIT перед основными вычислениями
Это поведение является нормальной особенностью работы JIT-компилятора .NET, а не ошибкой в вашем коде. Для максимальной производительности всегда учитывайте компромисс между гибкостью делегатов и прямыми вызовами методов.
Источники
- Can the C# compiler or JIT optimize away a method call in a lambda expression? – Stack Overflow
- C# delegate compiler optimisation – Stack Overflow
- Are deterministically unchangable Actions, and Funcs Inlined by the JIT? – Stack Overflow
- What optimization hints can I give to the compiler/JIT? – Stack Overflow
- Compiled C# Lambda Expressions Performance – Stack Overflow