В чем причина такой разной продолжительности выполнения одного и того же кода?

код:

 internal class Program
{
    private static void Main(string[] args)
    {
        const int iterCount = 999999999;

        var sum1 = 0;
        var sum2 = 0;

        using (new Dis())
        {
            var sw = DateTime.Now;
            for (var i = 0; i < iterCount; i++)
                sum1 += i;
            Console.WriteLine(sum1);
            Console.WriteLine(DateTime.Now - sw);
        }

        using (new Dis())
        {
            var sw = DateTime.Now;
            for (var i = 0; i < iterCount; i++)
                sum2 += i;
            Console.WriteLine(sum2);
            Console.WriteLine(DateTime.Now - sw);
        }

        Console.ReadLine();
    }

    private class Dis : IDisposable
    {
        public void Dispose(){}
    }
}

Два одинаковых блока в идентичных случаях.

Вывод:

2051657985
00:00:00.3690996
2051657985
00:00:02.2640266

Второй блок занимает 2,2 секунды! Но если избавиться от привычек, продолжительности стали такими же (~ 0,3 с, как и первый). Я пробовал с .net framework 4.5 и .net core 1.1, в выпуске, результаты одинаковы.

Может ли кто-нибудь объяснить это поведение?

Ответ 1

Вы должны посмотреть машинный код, который генерирует дрожание, чтобы увидеть основную причину. Используйте "Инструменты" > "Параметры" > "Отладка" > "Основные" > отключить параметр "Запретить оптимизацию JIT". Переключитесь на сборку Release. Установите точку останова на первом и втором циклах. Когда он работает, используйте Debug > Windows > Disassembly.

Вы увидите машинный код для тел цикла for:

                    sum1 += i;
00000035  add         esi,eax 

и

                    sum2 += i;
000000d9  add         dword ptr [ebp-24h],eax 

Или, другими словами, переменная sum1 хранится в регистре CPU esi. Но переменная sum2 хранится в памяти в кадре стека метода. Большая разница. Регистры очень быстры, память медленная. Память для фрейма стека будет находиться в кеше L1, а на современных машинах доступ к этому кешу имеет задержку в 3 цикла. Буфер хранилища будет быстро перегружен большим количеством операций записи, что приведет к остановке процессора.

Поиск способа хранения переменных в регистре CPU является одной из основных обязанностей по оптимизации дрожания. Но у этого есть ограничения, x86, в частности, имеет мало регистров. Когда все они израсходованы, джиттер не имеет опции, но вместо этого использует память. Обратите внимание, что оператор using имеет дополнительную скрытую локальную переменную под капотом, поэтому он имеет эффект.

В идеале оптимизатор джиттера сделал бы лучший выбор, как распределять регистры. Используя их для переменных цикла (что он и сделал) и сумма переменных. Компилятор с опережением времени получит это право, имея достаточно времени для выполнения анализа кода. Но компилятор "точно в срок" работает под строгими временными ограничениями.

Основные контрмеры:

  • Разделите код на отдельные методы, чтобы можно было повторно использовать регистр, такой как ESI.
  • Удалите форсирование дрожания (Project > Properties > вкладка Build > untick "Предпочитаете 32-битный" ). x64 содержит 8 дополнительных регистров.

Последняя пуля эффективна для устаревшего джиттера x64 (целевой .NET 3.5 для его использования), но не для переписывания джиттера x64 (aka RYuJIT), впервые сделанного в 4.6. Переписывать, что было необходимо, потому что унаследованный джиттер потребовал слишком много времени для оптимизации кода. Разочаровав, RyuJIT действительно умеет разочаровываться, я думаю, что его оптимизатор мог бы сделать здесь лучшую работу.