C# generated IL for ++ operator-когда и почему префикс / постфикс быстрее
поскольку этот вопрос касается оператора приращения и различий в скорости с префиксной / постфиксной нотацией, я очень тщательно опишу вопрос, чтобы Эрик Липперт не обнаружил его и не поджег меня!
(более подробную информацию и более подробную информацию о том, почему я спрашиваю, можно найти по адресу 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% медленнее, чем (1)/(2).
поэтому я заключаю, что нет никакого преимущества в производительности выбора префиксной нотации над постфиксной нотацией как таковой. Однако, когда результат работы фактически используется, то это приводит к более медленному коду, чем если бы он просто выбрасывался.
затем я посмотрел на сгенерированный IL с помощью рефлектора и нашел следующее:
количество байтов IL идентично во всех случаях.
The .maxstack варьировался от 4 до 6, но я считаю, что используется только для целей проверки и поэтому не имеет отношения к производительности.
(1) и (2) генерируется точно такой же IL, поэтому его нет удивительно, что время совпадало. Поэтому мы можем игнорировать (1).
(3) и (4) генерируется очень похожий код - единственное существенное различие заключается в позиционировании кода операции dup для учета результат работы. Опять же, неудивительно, что сроки совпадают.
поэтому я сравнил (2) и (3), чтобы узнать, что может объяснить разницу в скорости:
(2) использует ldloc.0 op дважды (один раз как часть индексатора, а затем позже как часть приращения).
(3) используется ldloc.0 сразу за ним следует DUP op.
таким образом, соответствующий 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 ответов
OK после многих исследований (грустно, я знаю!), Я думаю, ответили на мой собственный вопрос:
ответ может быть. По-видимому, компиляторы JIT ищут шаблоны (см. http://blogs.msdn.com/b/clrcodegeneration/archive/2009/08/13/array-bounds-check-elimination-in-the-clr.aspx) чтобы решить, когда и как можно оптимизировать проверку границ массива, но является ли это тем же шаблоном, о котором я догадывался, или нет, я не знаю.
в этом случае, это спорный вопрос, потому что относительное увеличение скорости (2) было связано с чем-то больше. Оказывается, компилятор x64 JIT достаточно умен, чтобы выяснить, является ли длина массива постоянной (и, по-видимому, также кратной числу разверток в цикле): поэтому код был только проверкой границ в конце каждой итерации, и каждый развернутый стал просто: -
total += intArray[j]; j++;
00000081 8B 44 0B 10 mov eax,dword ptr [rbx+rcx+10h]
00000085 03 F0 add esi,eax
Я доказал это, изменив приложение, чтобы размер массива был указан в командной строке и увидев другой ассемблер выход.
другие вещи, обнаруженные во время этого упражнения: -
- для автономной операции приращения (т. е. результат не используется) нет разницы в скорости между префиксом/постфиксом.
- когда в индексаторе используется операция приращения, ассемблер показывает, что префиксная нотация немного более эффективна (и настолько близка в исходном случае, что я предположил, что это просто несоответствие времени и назвал их равными - моя ошибка). Разница более выражен при компиляции как x86.
- цикл разворачивания действительно работает. По сравнению со стандартным циклом с оптимизацией границ массива, 4 rollups всегда давали улучшение 10% -20% (и x64/constant case 34%). Увеличение количества роллупов дало различное время с некоторыми очень медленными темпами в случае постфикса в индексаторе, поэтому я буду придерживаться 4, Если разверну и только изменю это после обширного времени для конкретного случая.
интересные результаты. Я бы сделал вот что:--1-->
- перепишите приложение, чтобы сделать весь тест дважды.
- поместите окно сообщения между двумя тестовыми запусками.
- Compile для выпуска, без оптимизации и так далее.
- запустить исполняемый файл вне отладчика.
- когда появится окно сообщения, прикрепите отладчик
- Теперь проверьте код, сгенерированный для двух разных случаев дрожание.
и тогда вы узнаете, делает ли дрожание лучшую работу с одним, чем с другим. Например, дрожание может осознавать, что в одном случае оно может удалить проверки границ массива, но не осознавать этого в другом случае. Не знаю, я не специалист по дрожанию.
причина всего rigamarole заключается в том, что дрожание может генерировать другой код, когда отладчик подключен. Если вы хотите знать, что он делает при нормальных обстоятельствах затем вы должны убедиться, что код получает jitted при нормальных, не-отладчик обстоятельствах.
Я люблю тестирование производительности, и я люблю быстрые программы, поэтому я восхищаюсь вашим вопросом.
Я попытался воспроизвести ваши выводы так и не удалось. В моей системе Intel i7 x64 выполняются образцы кода .Net4 framework в конфигурации x86|Release все четыре тестовых случая производили примерно одинаковые тайминги.
для выполнения теста я создал совершенно новый проект консольного приложения и использовал QueryPerformanceCounter
вызов API для получения таймера на основе процессора с высоким разрешением. Я попробовал два параметры jmax
:
jmax = 1000
jmax = 1000000
потому что локальность массива часто может иметь большое значение в том, как ведет себя производительность и размер цикла увеличивается. Однако оба размера массива в моих тестах вели себя одинаково.
Я сделал много оптимизации производительности и одна из вещей, которые я узнал, что вы можете очень легко оптимизировать приложение так, что оно работает быстрее на один конкретный компьютер в то время как непреднамеренно заставляя его работать медленнее на другом компьютере.
Я не говорю здесь гипотетически. Я настроил внутренние циклы и вылил часы и дни работы, чтобы заставить программу работать быстрее, только чтобы мои надежды были разбиты, потому что я оптимизировал ее на своей рабочей станции, а целевой компьютер был другой моделью процессора Intel.
Итак, мораль этой истории:
- фрагмент кода (2) работает быстрее, чем код snippet (3) на вашем компьютере, но не на моем компьютере
вот почему некоторые компиляторы имеют специальные переключатели оптимизации для разных процессоров или некоторые приложения поставляются в разных версиях, хотя одна версия может легко работать на всех поддерживаемых аппаратных средствах.
таким образом, если вы собираетесь делать тестирование, как это, вы должны сделать это так же, как писатели компилятора JIT: вы должны выполнить свои тесты на самых разных аппаратных средствах, а затем выбрать blend, счастливая среда, которая дает лучшую производительность на самом вездесущем оборудовании.