Странное поведение производительности
Итак, у меня есть два метода, которые предполагают умножить массив целых чисел на 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.
}
}
}