Ответ 1
Для вышеперечисленных функций я получил точный код сборки (используя cmovne).
Конечно, некоторые компиляторы могут сделать эту оптимизацию, но она не гарантируется. Очень возможно, что вы получите другой объектный код для двух способов записи функции.
На самом деле оптимизация не гарантируется (хотя современные оптимизирующие компиляторы большую часть времени выполняют впечатляющую работу), поэтому вы должны либо написать код для захвата семантического значения, которое вы намерены использовать, либо вы должны проверить сгенерированный объектный код и написать код, чтобы обеспечить получение ожидаемого результата.
Вот то, что старые версии MSVC будут генерироваться при таргетинге на x86-32 (в основном потому что они не знают, использовать инструкцию CMOV):
test0 PROC
cmp BYTE PTR [c], 99
mov eax, DWORD PTR [x]
mov DWORD PTR [eax], 0
jne SHORT LN2
mov DWORD PTR [eax], 15
LN2:
ret 0
test0 ENDP
test1 PROC
mov eax, DWORD PTR [x]
xor ecx, ecx
cmp BYTE PTR [c], 99
setne cl
dec ecx
and ecx, 15
mov DWORD PTR [eax], ecx
ret 0
test1 ENDP
Обратите внимание, что test1
дает вам нераспределенный код, который использует инструкцию SETNE
(условный набор, который устанавливает его операнд в 0 или 1 на основе кода условия - в этом случае NE
) в сочетании с некоторые бит-манипуляции для получения правильного значения. test0
использует условную ветвь, чтобы пропустить назначение от 15 до *x
.
Причина, по которой это интересно, состоит в том, что она почти полностью противоположна тому, что вы можете ожидать. Наивно, можно было бы ожидать, что test0
будет таким, каким вы могли бы удерживать руку оптимизатора и заставить ее генерировать нерасширяющийся код. По крайней мере, это первая мысль, которая прошла через мою голову. Но на самом деле это не так! Оптимизатор способен распознавать идиому if
/else
и оптимизировать соответственно! Он не может сделать такую же оптимизацию в случае test0
, где вы пытались перехитрить ее.
Однако при добавлении дополнительной переменной... Узел внезапно становится другим
Что ж, не удивительно. Небольшое изменение кода часто может оказать значительное влияние на испускаемый код. Оптимизаторы не волшебны; они просто очень сложные шаблоны. Вы изменили шаблон!
Конечно, оптимизирующий компилятор мог бы использовать два условных шага здесь для создания нераспределенного кода. Фактически именно это и делает Clang 3.9 для test3
(но не для test2
, что соответствует нашему вышеописанному анализу, показывающему, что оптимизаторы могут лучше распознать стандартные шаблоны, чем необычные). Но GCC этого не делает. Опять же, нет гарантии выполнения конкретной оптимизации.
Кажется, что единственное различие заключается в том, что верхние "mov" выполняются до или после "je".
Теперь (извините, что моя сборка немного грубая), разве всегда лучше иметь movs после прыжка, чтобы сохранить сброс трубопровода?
Нет, не совсем. Это не улучшит код в этом случае. Если ветка неверно предсказана, у вас будет флеш-конвейер, несмотря ни на что. Не имеет значения, является ли speculatively mispredicted code инструкцией ret
или если это инструкция mov
.
Единственная причина, по которой важно, что инструкция ret
сразу же следует за условной ветвью, - это если вы написали код сборки вручную и не знали, использовать инструкцию rep ret
. Это трюк, необходимый для некоторых процессоров AMD, которые позволяют избежать штрафа за ветвление. Если бы вы не были гуру собрания, вы, вероятно, не знали бы этого трюка. Но компилятор делает это, а также знает, что это не обязательно, когда вы специально нацеливаете процессор Intel или другое поколение процессора AMD, у которого нет этой причуды.
Однако вы можете быть правы в том, что лучше иметь mov
после ветки, но не по той причине, которую вы предложили. Современные процессоры (я считаю, что это Nehalem и позже, но я бы посмотрел в Agner Fog отличные руководства по оптимизации, если мне нужно было проверить) в определенных обстоятельствах способны к макрооперационному слиянию. В принципе, слияние с макро-операцией означает, что процессорный декодер объединяет две подходящие инструкции в один микрооператор, сохраняя пропускную способность на всех этапах конвейера. Команда cmp
или test
, за которой следует инструкция условного перехода, как вы видите в test3
, имеет право на слияние макро-op (на самом деле существуют другие условия, которые должны выполняться, но этот код соответствует этим требованиям). Планирование других инструкций между cmp
и je
, как вы видите в test2
, делает невозможным слияние макросов, что может привести к медленному выполнению кода.
Возможно, однако, это недостаток оптимизации в компиляторе. Он мог бы переупорядочить инструкции mov
, чтобы поместить je
сразу после cmp
, сохраняя возможность слияния макросов:
test2a(char, int*, int*):
mov DWORD PTR [rsi], 0 ; do the default initialization *first*
mov DWORD PTR [rdx], 0
cmp dil, 99 ; this is now followed immediately by the conditional
je .L10 ; branch, making macro-op fusion possible
rep ret
.L10:
mov DWORD PTR [rsi], 15
mov DWORD PTR [rdx], 21
ret
Другим различием между объектным кодом для test2
и test3
является размер кода. Благодаря дополнению, испускаемому оптимизатором для выравнивания цели ветвления, код для test3
составляет 4 байта больше, чем test2
. Очень маловероятно, что это достаточное различие с материей, тем не менее, особенно если этот код не выполняется в узком цикле, где в кеше гарантированно будет горячий.
Итак, значит ли это, что вы всегда должны писать код, как вы делали в test2
?
Ну, нет, по нескольким причинам:
- Как мы видели, это может быть пессимизация, поскольку оптимизатор может не распознать шаблон.
- Сначала вы должны написать код для удобочитаемости и семантической корректности, только для того, чтобы оптимизировать его, когда ваш профилировщик указывает, что это на самом деле узкое место. И тогда вы должны оптимизировать только после проверки и проверки объектного кода, испускаемого вашим компилятором, иначе вы могли бы пессимизировать. (Стандарт "доверяй своему компилятору, пока не будет доказано иначе" ).
-
Несмотря на то, что он может быть оптимальным в некоторых очень простых случаях, "запрограммированная" идиома не является обобщаемой. Если ваша инициализация занимает много времени, возможно, быстрее пропустить ее, когда это возможно. (Здесь рассматривается один пример в контексте VB 6, где манипуляции с строкой настолько медленны, что, когда это возможно, происходит, когда это возможно, на самом деле приводит к более быстрому времени выполнения, чем причудливый отрыв кода. В более общем плане, такое же обоснование применимо, если бы вы могли развернуть вызов функции.)
Даже здесь, где он, кажется, приводит к очень простому и, возможно, более оптимальному коду, он может быть медленнее, потому что вы дважды записываете в память в случае, когда
c
равно 99, и ничего не сохраняет в этом случае гдеc
не равно 99.Вы можете сэкономить эту стоимость, переписав код таким образом, чтобы он накапливал окончательное значение во временном регистре, сохраняя его только в памяти в конце, например:
test2b(char, int*, int*): xor eax, eax ; pre-zero the EAX register xor ecx, ecx ; pre-zero the ECX register cmp dil, 99 je Done mov eax, 15 ; change the value in EAX if necessary mov ecx, 21 ; change the value in ECX if necessary Done: mov DWORD PTR [rsi], eax ; store our final temp values to memory mov DWORD PTR [rdx], ecx ret
но это сгибает два дополнительных регистра (
eax
иecx
) и на самом деле может быть не быстрее. Вы должны были бы сравнить это. Или доверять компилятору испускать этот код, когда он фактически оптимален, например, когда он вложил функцию, подобнуюtest2
, в замкнутый цикл. -
Даже если вы могли бы гарантировать, что запись кода определенным образом приведет к тому, что компилятор будет выдавать нераспространяемый код, это не обязательно будет быстрее! В то время как ветки медленны, когда они ошибочно предсказаны, ошибочные предсказания на самом деле довольно редки. Современные процессоры имеют чрезвычайно хорошие двигатели прогнозирования ветвей, обеспечивая в большинстве случаев точность прогнозирования более 99%.
Условные ходы отлично подходят для предотвращения неверных предсказаний отрасли, но они имеют важный недостаток в увеличении длины цепочки зависимостей. Напротив, правильно предсказанная ветвь нарушает цепочку зависимостей. (Вероятно, именно поэтому GCC не испускает две команды CMOV при добавлении дополнительной переменной.) Условное перемещение - это только победа в производительности, если вы ожидаете, что предсказание ветвления завершится неудачей. Если вы можете рассчитывать на коэффициент успешности предсказания ~ 75% или лучше, условная ветвь, вероятно, быстрее, потому что она нарушает цепочку зависимостей и имеет более низкую задержку. И я подозреваю, что это будет иметь место здесь, если
c
не будет чередоваться между 99 и не 99 каждый раз, когда вызывается функция. (См. Agner Fog "Оптимизация подпрограмм на языке ассемблера" , стр. 70-71.)