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

Итак, у меня есть два метода, которые предполагают умножить массив целых чисел на 1000 элементов на 2. Первый метод:

[MethodImpl(MethodImplOptions.NoOptimization)]
Power(int[] arr)
{
    for (int i = 0; i < arr.Length; i++)
    {
        arr[i] = arr[i] + arr[i];
    }
}

Второй метод:

[MethodImpl(MethodImplOptions.NoOptimization)]
PowerNoLoop(int[] arr)
{
    int i = 0;
    arr[i] = arr[i] + arr[i];
    i++;
    arr[i] = arr[i] + arr[i];
    i++;
    arr[i] = arr[i] + arr[i];
    i++;
    ............1000 Times........
    arr[i] = arr[i] + arr[i];
}

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

Удивительный результат: Power быстрее почти на 50%, чем PowerNoLoop, хотя я проверил декомпилированный IL источник обоих из них, а содержимое цикла for точно такое же, как и каждая строка в PowerNoLoop. Как это может быть?

Ответы

Ответ 1

Измерение выборки с моей машины, запуск теста 10 раз, PowerNoLoop является первым:

00:00:00.0277138 00:00:00.0001553
00:00:00.0000142 00:00:00.0000057
00:00:00.0000106 00:00:00.0000053
00:00:00.0000084 00:00:00.0000053
00:00:00.0000080 00:00:00.0000053
00:00:00.0000075 00:00:00.0000053
00:00:00.0000080 00:00:00.0000057
00:00:00.0000080 00:00:00.0000053
00:00:00.0000080 00:00:00.0000053
00:00:00.0000075 00:00:00.0000053

Да, примерно на 50% медленнее. Примечательным является джиттер над головой в первом проходе через испытание, очевидно, он сжигает гораздо больше ядра пытается получить этот огромный метод скомпилирован. Имейте в виду, что измерение сильно отличается, когда вы не отключили оптимизатор, версия не-цикл, то ~ 800% медленнее.

Первое место, чтобы всегда искать объяснение - это сгенерированный машинный код, вы можете увидеть его с помощью Debug > Windows > Disassembly. Основной проблемой является пролог метода PowerNoLoop(). Похож на код x86:

067E0048  push        ebp                       ; setup stack frame
067E0049  mov         ebp,esp  
067E004B  push        edi                       ; preserve registers
067E004C  push        esi  
067E004D  sub         esp,0FA8h                 ; stack frame size = 4008 bytes  
067E0053  mov         esi,ecx  
067E0055  lea         edi,[ebp-0ACCh]           ; temp2 variables
067E005B  mov         ecx,2B1h                  ; initialize 2756 bytes
067E0060  xor         eax,eax                   ; set them to 0
067E0062  rep stos    dword ptr es:[edi] 

Обратите внимание на размер большого размера, 4008 байт. Слишком много для метода с только одной локальной переменной, он должен содержать только 8 байтов. Дополнительные 4000 из них являются временными переменными, я назвал их temp2. Они инициализируются 0 командой rep stos, которая занимает некоторое время. Я не могу объяснить 2756.

Индивидуальные добавления - это очень сложное дело в неоптимизированном коде. Я пощажу вам дамп машинного кода и напишу его в эквивалентном С# -коде:

if (i >= arr.Length) goto throwOutOfBoundsException
var temp1 = arr[i];
if (i >= arr.Length) goto throwOutOfBoundsException
var temp2 = temp1 + arr[i];
if (i >= arr.Length) goto throwOutOfBoundsException
arr[i] = temp2

Повторяется снова и снова, в тысячу раз больше. Переменная temp2 является нарушителем спокойствия, по одному для каждого отдельного оператора. Таким образом, добавление 4000 байт к размеру стека. Если у кого-то есть догадка в 2756, я бы хотел услышать это в комментарии.

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

Примечательным, а в том, что они не будут устранены при удалении [MethodImpl] атрибут и позволяет оптимизатору делать свою работу. На самом деле этот метод не оптимизирован, потому что он не хочет решать такой большой фрагмент кода.


Вывод должен состоять в том, чтобы всегда оставлять его до оптимизатора джиттера для разворачивания петель для вас. Он знает лучше.

Ответ 2

Ханс Пассант, похоже, поразил основные проблемы на голове, но пропустил некоторые моменты.

Во-первых, как говорит Марк Янсен, генератор кода (в JIT) имеет специальный случай для удаления проверки связей для простого доступа к массиву в простых для циклов. Очень вероятно, что [MethodImpl(MethodImplOptions.NoOptimization)] не влияет на это. Ваш развернутый цикл должен делать эту проверку 3000 раз!

Следующая проблема заключается в том, что для чтения данных (или кода) из памяти требуется гораздо больше времени, чем при выполнении инструкции, которая уже находится в кеше 1-го уровня процессора. Существует также ограниченная пропускная способность от ЦП к ОЗУ, поэтому всякий раз, когда процессор считывает инструкцию из памяти, он не может читать (или обновлять) массив. Как только цикл в Power выполняется в первый раз, все инструкции процессора будут в кеше 1-го уровня - они могут даже храниться в частично декодированной форме.

Обновление 1000 различных переменных tempN приведет к загрузке кэша ЦП и, возможно, даже ОЗУ (поскольку ЦП не знает, что они не будут прочитаны снова, поэтому их необходимо сохранить в ОЗУ) (Без MethodImplOptions.NoOptimization, JIT может объединить переменные tempN в несколько переменных, которые затем будут помещаться в регистры.)

В наши дни большинство процессоров могут одновременно запускать множество инструкций (Superscalar), поэтому очень вероятно, что все проверки цикла (1 < Arr.Length) и т.д. Выполняются одновременно с хранилищем/загрузкой из массива. Даже условный GoTo в конце цикла с помощью Спекулятивное выполнение (и/или Выполнение вне порядка).

Любой разумный процессор сможет запустить ваш цикл примерно в то время, которое требуется для чтения/записи значения из памяти.

Если вы сделали тот же тест 20 лет назад на ПК, то, скорее всего, вы получили бы результат, которого вы ожидали.

Ответ 3

Поскольку компилятор С# jit оптимизирован для устранения ограничений, если он может вывести, что переменная не выйдет за пределы цикла for.

Случай с for (int i = 0; i < arr.Length; i++) улавливается оптимизатором, а другой - нет.

Вот сообщение в блоге об этом, это долго, но стоит прочитать: http://blogs.msdn.com/b/clrcodegeneration/archive/2009/08/13/array-bounds-check-elimination-in-the-clr.aspx

Ответ 4

Я не вижу этих результатов в своих тестах. Я подозреваю, что ваши тесты могут быть искажены сбором мусора.

Мои результаты тестирования для сборки релиза следующие (с использованием Visual Studio 2015,.Net 4.6, Windows 10):

64:

Power() took 00:00:01.5277909
PowerNoLoop() took 00:00:01.4462461
Power() took 00:00:01.5403739
PowerNoLoop() took 00:00:01.4038312
Power() took 00:00:01.5327902
PowerNoLoop() took 00:00:01.4318121
Power() took 00:00:01.5451933
PowerNoLoop() took 00:00:01.4252743

x86:

Power() took 00:00:01.1769501
PowerNoLoop() took 00:00:00.9933677
Power() took 00:00:01.1557201
PowerNoLoop() took 00:00:01.0033348
Power() took 00:00:01.1119558
PowerNoLoop() took 00:00:00.9588702
Power() took 00:00:01.1167853
PowerNoLoop() took 00:00:00.9553292

И код:

using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace ConsoleApplication1
{
    internal class Program
    {
        private static void Main()
        {
            Stopwatch sw = new Stopwatch();

            int count = 200000;
            var test = new int[1000];

            for (int trial = 0; trial < 4; ++trial)
            {
                sw.Restart();

                for (int i = 0; i < count; ++i)
                    Power(test);

                Console.WriteLine("Power() took " + sw.Elapsed);
                sw.Restart();

                for (int i = 0; i < count; ++i)
                    PowerNoLoop(test);

                Console.WriteLine("PowerNoLoop() took " + sw.Elapsed);
            }
        }

        [MethodImpl(MethodImplOptions.NoOptimization)]
        public static void Power(int[] arr)
        {
            for (int i = 0; i < arr.Length; i++)
            {
                arr[i] = arr[i] + arr[i];
            }
        }

        [MethodImpl(MethodImplOptions.NoOptimization)]
        public static void PowerNoLoop(int[] arr)
        {
            int i = 0;
            arr[i] = arr[i] + arr[i];
            ++i;
            <snip> Previous two lines repeated 1000 times.
        }
    }
}