Почему добавление локальных переменных делает код .NET медленнее

Почему комментирует первые две строки этого цикла for и раскомментирует третий результат при ускорении на 42%?

int count = 0;
for (uint i = 0; i < 1000000000; ++i) {
    var isMultipleOf16 = i % 16 == 0;
    count += isMultipleOf16 ? 1 : 0;
    //count += i % 16 == 0 ? 1 : 0;
}

За временем очень сильно отличается код сборки: 13 против 7 инструкций в цикле. Платформа - это Windows 7 с .NET 4.0 x64. Оптимизация кода включена, и тестовое приложение запускалось за пределами VS2010. [ Обновление: Проект Repro, полезный для проверки настроек проекта.]

Устранение промежуточной булевой является фундаментальной оптимизацией, одной из самых простых в моей эре 1980 года Книга Дракона. Как оптимизация не применялась при создании CIL или JITing машинного кода x64?

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

Обновление: Разница скорости также возникает для x86, но зависит от порядка, который методы компилируются точно. См. Почему порядок JIT влияет на производительность?

Код сборки (по запросу):

    var isMultipleOf16 = i % 16 == 0;
00000037  mov         eax,edx 
00000039  and         eax,0Fh 
0000003c  xor         ecx,ecx 
0000003e  test        eax,eax 
00000040  sete        cl 
    count += isMultipleOf16 ? 1 : 0;
00000043  movzx       eax,cl 
00000046  test        eax,eax 
00000048  jne         0000000000000050 
0000004a  xor         eax,eax 
0000004c  jmp         0000000000000055 
0000004e  xchg        ax,ax 
00000050  mov         eax,1 
00000055  lea         r8d,[rbx+rax] 
    count += i % 16 == 0 ? 1 : 0;
00000037  mov         eax,ecx 
00000039  and         eax,0Fh 
0000003c  je          0000000000000042 
0000003e  xor         eax,eax 
00000040  jmp         0000000000000047 
00000042  mov         eax,1 
00000047  lea         edx,[rbx+rax] 

Ответ 1

Вопрос должен быть "Почему я вижу такую ​​разницу на моей машине?". Я не могу воспроизвести такую ​​огромную разницу в скорости и подозреваю, что в вашей среде есть что-то конкретное. Очень сложно сказать, что это может быть. Могут быть некоторые (компиляторы) параметры, которые вы установили некоторое время назад и забыли о них.

Я создаю консольное приложение, перестраиваю в режиме Release (x86) и запускаю VS. Результаты практически идентичны, 1,77 секунды для обоих методов. Вот точный код:

static void Main(string[] args)
{
    Stopwatch sw = new Stopwatch();
    sw.Start();
    int count = 0;

    for (uint i = 0; i < 1000000000; ++i)
    {
        // 1st method
        var isMultipleOf16 = i % 16 == 0;
        count += isMultipleOf16 ? 1 : 0;

        // 2nd method
        //count += i % 16 == 0 ? 1 : 0;
    }

    sw.Stop();
    Console.WriteLine(string.Format("Ellapsed {0}, count {1}", sw.Elapsed, count));
    Console.ReadKey();
}

Пожалуйста, у кого есть 5 минут, скопируйте код, перестройте, запустите VS и опубликуйте результаты в комментариях к этому ответу. Я бы хотел не сказать "это работает на моей машине".

ИЗМЕНИТЬ

Чтобы убедиться, что я создал приложение 64-разрядное Winforms, и результаты аналогичны результатам в вопросе. первый метод медленнее (1,57 с), чем второй (1.05 сек). Разница, которую я наблюдаю, составляет 33% - еще много. Кажется, есть ошибка в .NET4 64-битном JIT-компиляторе.

Ответ 2

Я не могу говорить с компилятором .NET или его оптимизацией или даже КОГДА он выполняет свои оптимизации.

Но в этом конкретном случае, если компилятор сбрасывал эту логическую переменную в фактический оператор, и вы должны были попробовать и отлаживать этот код, оптимизированный код не будет соответствовать коду как написанному. Вы не сможете сделать один шаг над назначением isMulitpleOf16 и проверить его значение.

Это просто пример того, где оптимизация может быть отключена. Могут быть и другие. Оптимизация может произойти во время фазы загрузки кода, а не фазы генерации кода из CLR.

Современные среды выполнения довольно сложны, особенно если вы используете JIT и динамическую оптимизацию во время выполнения. Я чувствую благодарность, что код делает то, что он говорит, иногда.

Ответ 3

Это ошибка в .NET Framework.

Ну, на самом деле я просто размышляю, но я представил отчет об ошибке Microsoft Connect, чтобы узнать, что они говорят. После того, как Microsoft удалила этот отчет, я повторно отправил его в проект roslyn на GitHub.

Обновление: Microsoft перенесла проблему в проект coreclr. Из комментариев по этой проблеме, называя это ошибкой, кажется немного сильным; это скорее недостающая оптимизация.

Ответ 4

Я думаю, что это связано с другим вопросом. Когда я меняю свой код следующим образом, выигрывает многострочная версия.

oops, только на x86. На x64 многострочная линия является самой медленной, и условное выражение их обоих удобно.

class Program
{
    static void Main()
    {
        ConditionalTest();
        SingleLineTest();
        MultiLineTest();
        ConditionalTest();
        SingleLineTest();
        MultiLineTest();
        ConditionalTest();
        SingleLineTest();
        MultiLineTest();
    }

    public static void ConditionalTest()
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        int count = 0;
        for (uint i = 0; i < 1000000000; ++i) {
            if (i % 16 == 0) ++count;
        }
        stopwatch.Stop();
        Console.WriteLine("Conditional test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
    }

    public static void SingleLineTest()
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        int count = 0;
        for (uint i = 0; i < 1000000000; ++i) {
            count += i % 16 == 0 ? 1 : 0;
        }
        stopwatch.Stop();
        Console.WriteLine("Single-line test --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
    }

    public static void MultiLineTest()
    {
        Stopwatch stopwatch = new Stopwatch();
        stopwatch.Start();
        int count = 0;
        for (uint i = 0; i < 1000000000; ++i) {
            var isMultipleOf16 = i % 16 == 0;
            count += isMultipleOf16 ? 1 : 0;
        }
        stopwatch.Stop();
        Console.WriteLine("Multi-line test  --> Count: {0}, Time: {1}", count, stopwatch.ElapsedMilliseconds);
    }
}

Ответ 5

Я обычно думаю об этом так: люди, которые работают над компилятором, могут делать столько всего в год. Если бы за это время они могли реализовать лямбды или множество классических оптимизаций, я бы проголосовал за лямбда. С# - это язык, который эффективен с точки зрения чтения и записи кода, а не с точки зрения времени выполнения.

Поэтому разумно для команды сосредоточиться на функциях, которые максимизируют эффективность чтения/записи, а не эффективности исполнения в определенном случае с углом (из которых, вероятно, тысячи).

Первоначально, я полагаю, идея заключалась в том, что JITTER выполнит всю оптимизацию. К сожалению, JITting занимает заметное количество времени, и любая передовая оптимизация сделает его еще хуже. Так что это не сработало, как можно было надеяться.

Одна вещь, которую я нашел о программировании действительно быстрого кода на С#, заключается в том, что довольно часто вы сталкиваетесь с серьезным узким местом GC до того, как любая оптимизация, как вы упомянули, изменит ситуацию. Например, если вы выделяете миллионы объектов. С# оставляет вам очень мало шансов избежать затрат: вместо этого вы можете использовать массивы структур, но полученный код действительно уродлив в сравнении. Моя точка зрения заключается в том, что многие другие решения о С# и .NET делают такие конкретные оптимизации менее полезными, чем они были бы в компиляторе С++. Heck, они даже снизили оптимизацию для конкретного процессора в NGEN, эффективность торговли для эффективности программы (отладчика).

Сказав все это, мне бы понравился С#, который фактически использовал оптимизации, которые С++ использовал с 1990-х годов. Только не за счет таких функций, как, скажем, async/await.