Подтвердить что ты не робот

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

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

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

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

(1) Отдельный, префикс:

    for (var j = 0; j != jmax;) { total += intArray[j]; ++j; }

(2) Отдельно, Postfix:

    for (var j = 0; j != jmax;) { total += intArray[j]; j++; }

(3) Indexer, Postfix:

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

Итак, я сравнил (2) и (3), чтобы узнать, что могло бы объяснить разницу в скорости:

  • (2) дважды использует ldloc.0 op (один раз как часть индексатора, а затем позже как часть приращения).

  • (3) используется ldloc.0, за которым сразу следует дуп 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 и оптимизируя ее? (и наличие a dup в (3) и (4) нарушает этот шаблон, и поэтому оптимизация пропущена)

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

4b9b3361

Ответ 1

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

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

Ответ 2

Интересные результаты. Я бы сделал следующее:

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

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

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

Ответ 3

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

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

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

  • jmax = 1000
  • jmax = 1000000

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

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

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

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

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

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

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