C# сгенерированный оператор IL для ++ - когда и почему префикс / постфиксная запись быстрее
Поскольку этот вопрос касается оператора приращения и разницы в скорости с помощью префикса / постфикса, я опишу вопрос очень осторожно, чтобы Эрик Липперт не обнаружил его и не поджег меня!
(дальнейшую информацию и более подробную информацию о том, почему я спрашиваю, можно найти по адресу http://www.codeproject.com/KB/cs/FastLessCSharpIteration.aspx?msg=3899456)
У меня есть четыре фрагмента кода:
(1) Отдельный префикс:
for (var j = 0; j != jmax;) { total += intArray[j]; ++j; }
(2) отдельно, постфикс:
for (var j = 0; j != jmax;) { total += intArray[j]; j++; }
(3) индексатор, постфикс:
for (var j = 0; j != jmax;) { total += intArray[j++]; }
(4) индексатор, префикс:
for (var j = -1; j != last;) { total += intArray[++j]; } // last = jmax - 1
То, что я пытался сделать, это доказать / опровергнуть, есть ли разница в производительности между префиксной и постфиксной нотацией в этом контексте (т. Е. Локальная переменная, поэтому не изменчивая, не изменяемая из другого потока и т. Д.), И если есть, почему это будет,
Скоростное тестирование показало, что:
(1) и (2) работают с одинаковой скоростью.
(3) и (4) работают с одинаковой скоростью.
(3) / (4) на ~27% медленнее, чем (1) / (2).
Поэтому я прихожу к выводу, что нет никакого преимущества в производительности, если выбирать префиксную нотацию вместо постфиксной. Однако, когда результат операции фактически используется, то это приводит к более медленному коду, чем если бы он просто выбрасывался.
Затем я посмотрел на сгенерированный IL с помощью Reflector и обнаружил следующее:
Количество байтов IL одинаково во всех случаях.
.Maxstack варьировался от 4 до 6, но я считаю, что он используется только для целей проверки и поэтому не имеет отношения к производительности.
(1) и (2) генерировали точно такой же IL, поэтому неудивительно, что время было идентичным. Таким образом, мы можем игнорировать (1).
(3) и (4) сгенерировали очень похожий код - единственное существенное отличие заключается в расположении дублирующего кода операции для учета результата операции. Опять же, не удивительно, что время совпадает.
Поэтому я сравнил (2) и (3), чтобы выяснить, что может объяснить разницу в скорости:
(2) дважды использует опцию ldloc.0 (один раз как часть индексатора, а затем как часть приращения).
(3) использовал ldloc.0, за которым сразу последовала операция дублирования.
Таким образом, релевантный IL для увеличения j для (1) (и (2)):
// ldloc.0 already used once for the indexer operation higher up
ldloc.0
ldc.i4.1
add
stloc.0
(3) выглядит так:
ldloc.0
dup // j on the stack for the *Result of the Operation*
ldc.i4.1
add
stloc.0
(4) выглядит так:
ldloc.0
ldc.i4.1
add
dup // j + 1 on the stack for the *Result of the Operation*
stloc.0
Теперь (наконец!) К вопросу:
(2) быстрее, потому что JIT-компилятор распознает шаблон ldloc.0/ldc.i4.1/add/stloc.0
как просто увеличить локальную переменную на 1 и оптимизировать ее?
(и наличие dup
в (3) и (4) нарушить этот шаблон, и поэтому оптимизация пропущена)
И дополнительное: если это так, то, по крайней мере, для (3) не будет заменять dup
с другим ldloc.0
повторно ввести этот образец?
3 ответа
Хорошо после долгих исследований (грустно, я знаю!), Я думаю, что ответил на мой собственный вопрос:
Ответ может быть. Очевидно, JIT-компиляторы действительно ищут шаблоны (см. http://blogs.msdn.com/b/clrcodegeneration/archive/2009/08/13/array-bounds-check-elimination-in-the-clr.aspx), чтобы принять решение когда и как можно оптимизировать проверку границ массивов, но не знаю, является ли это тем же шаблоном, о котором я догадывался, или нет.
В данном случае это спорный вопрос, потому что относительное увеличение скорости (2) было вызвано чем-то большим. Оказывается, что JIT-компилятор x64 достаточно умен, чтобы определить, является ли длина массива постоянной (и, по-видимому, также кратной числу развертываний в цикле): таким образом, код проверял только границы в конце каждой итерации, и каждая развертка стала просто:-
total += intArray[j]; j++;
00000081 8B 44 0B 10 mov eax,dword ptr [rbx+rcx+10h]
00000085 03 F0 add esi,eax
Я доказал это, изменив приложение, чтобы размер массива указывался в командной строке, и просматривал другой вывод ассемблера.
Другие вещи, обнаруженные во время этого упражнения:
- Для автономной операции приращения (т. Е. Результат не используется) нет разницы в скорости между префиксом / постфиксом.
- Когда в индексаторе используется операция приращения, ассемблер показывает, что запись префикса немного более эффективна (и настолько близка в исходном случае, что я предположил, что это просто временная несоответствие, и назвал их равными - моя ошибка). Разница более заметна при компиляции как x86.
- Развертывание петли работает. По сравнению со стандартным циклом с оптимизацией границ массива, 4 накопления всегда давали улучшение на 10%-20% (и x64/ постоянный случай 34%). Увеличение количества свертываний давало различную синхронизацию с некоторым намного более медленным в случае постфикса в индексаторе, поэтому я буду придерживаться 4 при развертывании и изменяю его только после длительной синхронизации для конкретного случая.
Интересные результаты. Что бы я сделал, это:
- Перепишите приложение, чтобы выполнить весь тест дважды.
- Поместите окно сообщения между двумя тестовыми прогонами.
- Компиляция для релиза, без оптимизации и так далее.
- Запустите исполняемый файл вне отладчика.
- Когда появится окно сообщения, прикрепите отладчик
- Теперь проверьте код, сгенерированный для двух разных случаев джиттером.
И тогда вы узнаете, справляется ли джиттер с одним лучше, чем с другим. Джиттер может, например, понимать, что в одном случае он может удалить проверки границ массива, но не осознавать этого в другом случае. Я не знаю; Я не эксперт по джиттеру.
Причина всего этого заключается в том, что при подключении отладчика джиттер может генерировать другой код. Если вы хотите знать, что он делает при нормальных обстоятельствах, то вы должны убедиться, что код работает в нормальных условиях, не связанных с отладкой.
Я люблю тестирование производительности и люблю быстрые программы, поэтому я восхищаюсь вашим вопросом.
Я пытался воспроизвести ваши выводы и потерпел неудачу. В моей системе Intel i7 x64, в которой выполнялись ваши примеры кода на платформе.NET4 в конфигурации x86|Release, во всех четырех тестовых примерах было примерно одинаковое время.
Для проведения теста я создал новый проект консольного приложения и использовал QueryPerformanceCounter
Вызов API для получения таймера высокого разрешения на базе процессора. Я пробовал две настройки для jmax
:
jmax = 1000
jmax = 1000000
потому что локальность массива часто может иметь большое значение в поведении и увеличении размера цикла. Однако оба размера массива вели себя одинаково в моих тестах.
Я провел большую оптимизацию производительности, и одна из вещей, которые я узнал, заключается в том, что вы можете очень легко оптимизировать приложение, чтобы оно работало быстрее на одном конкретном компьютере, в то же время непреднамеренно заставляя его работать медленнее на другом компьютере.
Я не говорю здесь гипотетически. Я настроил внутренние циклы и потратил часы и дни работы, чтобы заставить программу работать быстрее, только чтобы мои надежды рухнули, потому что я оптимизировал ее на своей рабочей станции, а целевой компьютер представлял собой другую модель процессора Intel.
Итак, мораль этой истории:
- Фрагмент кода (2) работает быстрее, чем фрагмент кода (3) на вашем компьютере, но не на моем компьютере
Вот почему некоторые компиляторы имеют специальные переключатели оптимизации для разных процессоров, или некоторые приложения выпускаются в разных версиях, даже если одна версия может легко работать на всех поддерживаемых аппаратных средствах.
Поэтому, если вы собираетесь проводить такое тестирование, вы должны сделать это так же, как пишут компиляторы JIT: вы должны выполнить свои тесты на широком спектре аппаратного обеспечения, а затем выбрать смесь, happy-medium, которая дает наилучшее производительность на самом распространенном оборудовании.