C # сгенерированный оператор IL для ++ - когда и почему префикс / постфиксная запись быстрее - PullRequest
20 голосов
/ 20 мая 2011

Поскольку этот вопрос касается оператора приращения и разницы в скорости с помощью префикса / постфикса, я опишу вопрос очень тщательно, чтобы Эрик Липперт не обнаружил его и не разжег меня!

(дополнительная информация и более подробная информация о причинахЯ прошу, можно найти на http://www.codeproject.com/KB/cs/FastLessCSharpIteration.aspx?msg=3899456#xx3899456xx/)

У меня есть четыре фрагмента кода следующим образом: -

(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% медленнееhan (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

Теперь (наконец!) К вопросу:

Is (2)) быстрее, потому что JIT-компилятор распознает шаблон ldloc.0/ldc.i4.1/add/stloc.0 как простое увеличение локальной переменной на 1 и ее оптимизацию?(и наличие dup в (3) и (4) нарушает этот шаблон, и поэтому оптимизация пропущена)

И дополнительно: если это так, то, по крайней мере, для (3),не заменит ли dup другим ldloc.0 повторный ввод этого паттерна?

Ответы [ 3 ]

10 голосов
/ 22 мая 2011

ОК после долгих исследований (грустно, я знаю!), Я думаю, что ответил на мой собственный вопрос:

Ответ: Возможно. Очевидно, 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 при развертывании и изменяю его только после длительной синхронизации для конкретного случая.
8 голосов
/ 21 мая 2011

Интересные результаты. Что бы я сделал, это:

  • Перепишите приложение, чтобы выполнить весь тест дважды.
  • Поместите окно сообщения между двумя тестами.
  • Компиляция для выпуска, без оптимизации и т. Д.
  • Запустить исполняемый файл вне отладчика .
  • Когда появится окно с сообщением, присоедините отладчик
  • Теперь проверьте код, сгенерированный для двух разных случаев джиттером.

И тогда вы узнаете, справляется ли джиттер с одним лучше, чем с другим. Джиттер может, например, понимать, что в одном случае он может удалить проверки границ массива, но не осознавать этого в другом случае. Я не знаю; Я не эксперт по джиттеру.

Причина всего ригамарола в том, что джиттер может генерировать другой код, когда подключен отладчик. Если вы хотите знать, что он делает в нормальных условиях, вы должны убедиться, что код работает в нормальных условиях, не связанных с отладкой.

7 голосов
/ 21 мая 2011

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

Я пытался воспроизвести ваши выводы и потерпел неудачу. В моей системе Intel i7 x64, в которой выполнялись ваши примеры кода на платформе .NET4 в конфигурации x86 | Release, во всех четырех тестовых примерах было примерно одинаковое время.

Для проведения теста я создал совершенно новый проект консольного приложения и использовал вызов API QueryPerformanceCounter для получения таймера высокого разрешения на базе процессора. Я попробовал две настройки для jmax:

  • jmax = 1000
  • jmax = 1000000

потому что локальность массива часто может сильно повлиять на поведение производительности и увеличение размера цикла. Однако оба размера массивов в моих тестах вели себя одинаково.

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

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

Итак, мораль этой истории:

  • Фрагмент кода (2) выполняется быстрее, чем фрагмент кода (3) на вашем компьютере, но не на моем компьютере

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

Так что, если вы собираетесь проводить тестирование таким образом, вы должны сделать это так же, как пишут компиляторы JIT: вам нужно выполнить свои тесты на широком спектре оборудования, а затем выбрать blend Happy-Medium, обеспечивающий наилучшую производительность на самом распространенном оборудовании.

...