С# сгенерировал IL для оператора ++ - когда и почему префиксная/постфиксная нотация быстрее

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

(дополнительную информацию и более подробную информацию о том, почему я спрашиваю, можно найти по адресу 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 с помощью 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 вновь вводить этот шаблон?


person Simon Hewitt    schedule 20.05.2011    source источник
comment
Если это то, что замедляет работу вашего приложения, тогда оно идеально, и вы можете удалить его.   -  person Yochai Timmer    schedule 20.05.2011
comment
Вы смотрели (измеряли) различия в оптимизации проверки привязки к массиву? Обычно это основной фактор, и все ваши сэмплы не соответствуют действительности. Вам следует беспокоиться о intArray[j]   -  person Henk Holterman    schedule 20.05.2011
comment
Когда вы делали свои тайминги, вы компилировали с Release и запускали без отладки (т.е. Ctrl+F5)?   -  person Jim Mischel    schedule 20.05.2011
comment
Почему вы используете !=jmax вместо <intArray.Length? <intArray.Length обычно заставляет оптимизатор понять, что он может не проверять границы массива.   -  person CodesInChaos    schedule 20.05.2011
comment
А почему бы вам не проверить сгенерированную сборку x86? Просто поставьте Debugger.Break(); перед своим кодом, подключите отладчик и получите ассемблерный код. Как сказал Джим, вы не должны запускать отладчик, а должны подключиться позже.   -  person CodesInChaos    schedule 20.05.2011
comment
@Yochai По крайней мере, разница, которую делают проверки границ, довольно часто значительна. Помещение моих циклов в форму, в которой компилятор удалил проверки границ, дало мне огромное ускорение.   -  person CodesInChaos    schedule 20.05.2011
comment
Спасибо за комментарии. Код такой, какой он есть, потому что он основан на чужом коде (как указано в ссылке на статью). Я знаю об оптимизации проверки границ массива и важности работы в режиме Release. Этот код является массивом, но в статье также сравниваются списки и другие структуры. Я даже не пытаюсь сделать код быстрее, а просто исследую, почему IL генерируется именно в этом сценарии.   -  person Simon Hewitt    schedule 20.05.2011
comment
+ за управление сложным процессом постановки вопроса, который, как вы прекрасно знаете, может быть флеймом, потому что речь идет о микрооптимизации :)   -  person Mike Dunlavey    schedule 21.05.2011
comment
@Mike Dunlavey: и это тоже. ;-)   -  person quentin-starin    schedule 21.05.2011


Ответы (3)


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

Ответ: Может быть. Очевидно, 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 свертки всегда давали улучшение на 10–20 % (а случай x64/константа — 34 %). Увеличение количества сверток привело к изменению времени, причем некоторые из них были намного медленнее в случае постфикса в индексаторе, поэтому я буду придерживаться 4 при развертывании и меняю это только после длительного времени для конкретного случая.
person Simon Hewitt    schedule 22.05.2011

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

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

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

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

person Eric Lippert    schedule 20.05.2011
comment
Спасибо, Эрик. Хотя я не сделал этого так, как вы упомянули, теперь у меня есть 8 копий сгенерированного вывода сборки (по 4 теста для X64 и X86) на основе режима выпуска, работающего извне и подключающего отладчик. Я не эксперт по сборке, но теперь я вижу некоторые закономерности. - person Simon Hewitt; 22.05.2011
comment
@Simon: Что касается предложений Эрика, в моих тестах, зная, что оптимизация отключена в отладчике, я проводил все свои тайминги вне отладчика. Поскольку все тайминги совпадали с точностью до нескольких процентов, я не видел необходимости исследовать язык ассемблера. Если разные люди будут тестировать один и тот же код, нам нужно, чтобы это был точно такой же код. Вы выполняете развертывание цикла и, перемещая оператор приращения, и это другой вопрос. - person Rick Sladkey; 22.05.2011
comment
Просто для пояснения, все мои тайминги были вне отладчика и вне VS. Я использовал отладчик только для подключения к уже запущенному приложению, чтобы получить JITted-сборку. - person Simon Hewitt; 22.05.2011

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

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

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

  • jmax = 1000
  • jmax = 1000000

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

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

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

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

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

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

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

person Rick Sladkey    schedule 20.05.2011
comment
Привет Рик. Я хотел, чтобы этот вопрос был в основном теоретическим о сгенерированном IL, поэтому свел код к абсолютному минимуму, но, поскольку вы попытались воспроизвести разницу во времени, я дам вам более подробную информацию. Размер массива был 16 000 000, но, что более важно, код, который я развернул каждый цикл 16 раз (просто скопировал строку 16 раз). Ничего особенного в ИЛ - то, что я процитировал выше, просто повторяется 16 раз и в ИЛ, так что я изначально не упоминал об этом. Моя машина - это i5 X64, работающий в режиме AnyCPU/Release. Я также попробую использовать режим x86, чтобы увидеть, имеет ли это значение. - person Simon Hewitt; 21.05.2011
comment
Как я уже сказал, это хороший вопрос. Интуитивно мы можем сказать, что компилятор(ы) должны обрабатывать их одинаково. Таким образом, теоретически общая загрузка и хранение IL должны быть одинаковыми. На практике JIT-компилятору может больше повезти с порядком операций одного, чем другого, и он может отличаться в зависимости от машины. - person Rick Sladkey; 21.05.2011
comment
Привет, Рик, теперь я видел некоторые из произведенных сборок, и тайминги действительно не должны совпадать. Могу ли я отправить вам копию приложения (140 строк) для повторного тестирования на вашем компьютере? Сейчас я прихожу к некоторым выводам, которые весьма интересны. - person Simon Hewitt; 22.05.2011
comment
@Simon: Найдите мое имя в Google. Вы сможете найти способ связаться со мной с первого обращения. - person Rick Sladkey; 22.05.2011