Я знаю, что movzx
можно использовать для разрыва зависимостей, но я наткнулся на некоторые movzx
способы использования как Clang, так и GCC, и я действительно не понимаю, для чего они нужны. Вот простой пример, который я попробовал в проводнике компилятора Godbolt:
#include <stdint.h>
int add2bytes(uint8_t* a, uint8_t* b) {
return uint8_t(*a + *b);
}
с GCC 12 -O3
:
add2bytes(unsigned char*, unsigned char*):
movzx eax, BYTE PTR [rsi]
add al, BYTE PTR [rdi]
movzx eax, al
ret
Если я правильно понимаю, первое movzx
здесь ломает зависимость от предыдущего значения eax
, но что делает второе movzx
? Я не думаю, что есть какая-то зависимость, которую он может сломать, и это также не должно влиять на результат.
с clang 14 -O3
еще более странно:
add2bytes(unsigned char*, unsigned char*): # @add2bytes(unsigned char*, unsigned char*)
mov al, byte ptr [rsi]
add al, byte ptr [rdi]
movzx eax, al
ret
Он использует mov
там, где movzx
кажется более разумным, а затем ноль расширяет al
до eax
, но не лучше ли сделать movzx
в начале?
У меня есть еще 2 примера: https://godbolt.org/z/z45xr4hq1
GCC генерирует как разумные, так и странные movzx
, а использование Clang mov r8 m
и movzx
просто не имеет для меня смысла. Я также попытался добавить -march=skylake
, чтобы убедиться, что это не функция для действительно старых архитектур, но сгенерированная сборка выглядит более или менее одинаково.
Ближайший пост, который я нашел, это https://stackoverflow.com/a/64915219/14730360, где они показали похожие movzx
варианты использования, которые кажутся бесполезными и/или неуместными.
Компиляторы действительно плохо используют movzx
здесь, или я что-то упускаю?
Обновлено: я открыл отчеты об ошибках для Clang и GCC:
https://github.com/llvm/llvm-project/issues/56498
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=106277
Временные обходные пути с использованием встроенной сборки: https://godbolt.org/z/7qob8G3j7
#define addb(a, b) asm (\
"addb %1, %b0"\
: "+r"(a) : "mi"(b))
int add2bytes(uint8_t* a, uint8_t* b) {
int ret = *a;
addb(ret, *b);
return ret;
}
Теперь Clang -O3 производит:
add2bytes(unsigned char*, unsigned char*): # @add2bytes(unsigned char*, unsigned char*)
movzx eax, byte ptr [rdi]
add al, byte ptr [rsi]
ret
Что вы называете "разорвать зависимость"?
У @YvesDaoust stackoverflow.com/a/43910889/14730360 есть пример того, как movzx разрывает зависимость и помогает выполнению OoO. Однако я не думаю, что movzx делает это здесь.
@YvesDaoust: Современные процессоры x86 имеют гораздо больше регистров, чем регистр имена. Вот почему физические регистры постоянно имеют значение переименован. Это сложный процесс. Одна важная проблема заключается в том, что он должен быть невидимым для кода. Это имеет значение при использовании частичных имен регистров, таких как al
. Этот должен сохраняет старшие биты, останавливая переименование. Но movzx
выполняет нулевое расширение, поэтому позволяет переименовывать.
Обновлен мой ответ с разделом о вашей ссылке Godbolt a[a[i] | a[j]]
, показывающим clang, ожидающим, что вызывающий абонент уже расширил i
и j
до 32-бит.
Финал MOVZX
обусловлен тем фактом, что функция возвращает int
, расширенный из байта. В версии clang это должен, но с gcc он лишний.
это не объясняет скомпилированную версию GCC в вопросе. Там eax
уже было очищено в первой инструкции.
@JakobStark: вы забываете о возможном переносе.
Перенос может быть и фактически игнорируется.
@YvesDaoust под переносом вы имеете в виду перенос, когда результат добавления больше 255? Я пробовал, и этого не происходит. Я имею в виду, что 8-битные инструкции в X86 не должны влиять на старшие биты, верно? Кстати, у меня также есть пример использования или в моей ссылке на Godbolt
@Hintro: ты прав, мой плохой. Затем clang должен делает расширение, а gcc делает это слишком часто.
Я не понимаю этот ответ. Соглашение о возврате состоит в том, что 32 бита в EAX должны быть правильными. Не важно ни на йоту, как эти биты оказались правильными. Первый movzx
в коде GCC обнуляет верхние 24 бита, и нет необходимости их «повторно обнулять».
8-битная инструкция добавлять складывает два 8-битных значения и сохраняет результат в младшем байте целевого регистра. О возможном переполнении и переносе сигнализируется с помощью флага переноса, нет — 9-го бита в регистре.
Просто чтобы быть более явным. C++ использует абстрактную модель для описания того, как достигается результат ("int, расширенный из байта"), но не требует, чтобы реализация следовала этой абстрактной модели. Кроме того, C++ нисколько не заботится о ненаблюдаемом C++ состоянии ЦП, таком как регистры переноса. Это не часть абстрактной модели и, следовательно, все равно.
Этот ответ на самом деле больше не является неправильным, но не очень полезным. Я бы порекомендовал удалить, так как я думаю, что другие ответы теперь достаточно охватывают тот факт, что результатом должно быть усеченное добавление с нулевым расширением до 32-битного. (В версии clang неправда, что movzx должен быть там, это может быть при начальной загрузке. Или мы могли бы начать с xor eax,eax
, чтобы добиться нулевого расширения другим способом; это было бы даже лучше для семейства P6, что позволяет нам закончить байтом add
без штрафа за неполную регистрацию для читателя.)
Бит лязга на самом деле кажется разумным. Вы получите неполный регистровый стойл, если будете писать в al
, а затем читать из eax
. Использование movzx
прерывает эту остановку частичного регистра.
Первоначальное перемещение в al
не зависит от существующих значений eax
(из-за переименования регистров), поэтому зависимости являются просто неизбежными зависимостями (ожидание [rsi], ожидание [rdi], ожидание завершения добавления перед нулевым расширением ).
Другими словами, старшие 24 бита должны быть обнулены, а младшие 8 битов должны быть вычислены, но эти два действия можно выполнять в любом порядке. clang просто выбирает сначала добавить, а потом ноль.
[РЕДАКТИРОВАТЬ]
Что касается GCC, это кажется особенно плохим выбором. Если бы он выбрал bl
в качестве временного регистра, последний movzx
был бы с нулевой задержкой на Haswell/SkyLake, но устранение перемещения не работает на al to eax.
Я думал, что срыва действительно больше не происходит со времен Core 2? по 2 путеводителям Агнера. Единственные, которые все еще могут быть проблемой, - это старшие 8 регистров. Согласно stackoverflow.com/q/45660139/14730360 movzx eax, al не устраняется и в каком-то смысле является штрафом, так что действительно ли стоит использовать его здесь?
@Hintro: Только Sandybridge и более поздние версии действительно сделали слияние с частичным регистром дешевым. Core2 и Nehalem вставляют объединяющуюся uop, но только после остановки примерно на 3 цикла IIRC. SnB вставляет слияние uop только с ожидаемой начальной стоимостью. (Если это не uop, объединяющий AH, тогда он должен выдаваться сам по себе). А HSW даже low8 не переименовывает отдельно от полного рега, поэтому код clang (по-прежнему) опасно живет с ложной зависимостью от старого значения RAX, лишь бы сохранить 1 байт размера кода в загрузке.
О, конечно, clang не заботится о старших 24-битных значениях выше al
, но ЦП, который не выполняет частичные переименования, должен отслеживать эти 24-битные значения до тех пор, пока они не будут обнулены — две инструкции ниже.
@Hintro: Вы правы, последний movzx eax, al
- глупый выбор обоих компиляторов. Поскольку нам нужно использовать 8-битный размер операнда для возврата (int)(uint8_t)sum
, на современных процессорах имеет смысл использовать movzx eax, byte [rdi]
/ add al, [rsi]
(код GCC без конечного movzx), с -mtune=haswell
или k8
.. znver3
, или если -mtune=generic
не меня не очень заботит семейство Intel P6. Если вы хотите закончить с movzx для Nehalem и более ранних версий, bl
будет плохим выбором, потому что RBX сохраняется при вызове. Но да, movzx edx, byte [rdi]
/ add dl, [rsi]
/ movzx eax, dl
было бы неплохо.
@Подсказка: вы можете сообщить об ошибке пропущенной оптимизации, особенно для clang. Их генератор кода уже является большим средним пальцем по отношению к семейству P6 большую часть времени с использованием частичного регистра, поэтому они, вероятно, будут заинтересованы в попытке создать версию с двумя инструкциями. github.com/llvm/llvm-проект/вопросы и gcc.gnu.org/bugzilla/enter_bug.cgi?product=gcc (используйте пропущенную оптимизацию ключевого слова для ошибок GCC. Не стесняйтесь ссылаться на этот пост о переполнении стека и / или цитировать любой из моих комментариев, если хотите.)
Оба компилятора здесь плохо справляются, но код clang особенно плох и нигде не имеет реальных преимуществ. И легко избежать недостатка во всем, кроме процессоров Intel десятилетней давности (которые переименовывают младшие 8 частичных регистров).
Оптимальный asm - это то, что вы предлагаете: movzx
загрузить, затем добавить байт, оставив uint8_t
результат в младшем байте, правильно расширенный до нуля до int
, как того требует семантика C. (Спасибо, что сообщили об этом вверх по течению: https://github.com/llvm/llvm-project/issues/56498 - я прокомментировал там, что movzx
является хорошей идеей для загрузки байтов в целом, даже когда LLVM не нуждается в расширении нуля до результата.)
Нужен movzx
где-то, но он может быть в начальной загрузке. (movzx
, как правило, хорошая идея для загрузки байтов в любом случае, чтобы избежать ложной зависимости от старого RAX; выбор clang для сохранения 1 байта, вероятно, не является хорошим, даже если ему не требуется отдельный movzx
сразу после .)
Здесь среди процессоров x86-64 есть в основном три релевантных поведения.
Ядро 2 / Неалем (64-битные члены семейства P6): AL переименовывается отдельно от RAX, если вы пишете AL. Более позднее чтение EAX остановит внешний интерфейс примерно на 3 цикла при вставке объединенной операции. Менее плохо, чем предыдущее семейство P6, но все же значительного штрафа, которого следует избегать. Но эти процессоры довольно устарели, и GCC -mtune=generic
не должен придавать большого значения последней версии GCC. (Особенно учитывая, что нынешнее ночное поведение GCC теперь не будет встроено в широко используемые бинарные пакеты в течение еще одного года или более, вероятно, большинством дистрибутивов стабильного выпуска.)
Возврат int
, когда последняя инструкция написала al
, скорее всего, приведет к штрафу, когда вызывающая сторона читает EAX. Но mov al, [rdi]
может работать без каких-либо ложных зависимостей или затрат на слияние.
Песчаный Мост и, возможно, Ivy Bridge: AL таки переименовывается отдельно, но объединяющую юоп можно вставить без всяких пробуксовок, в цикле с другими юопами.
mov al, [rdi]
по-прежнему не имеет ложных отложений или слияний uop. Но более позднее чтение EAX, которое запускает слияние uop (для слияния результата add al
со старшими байтами RAX из movzx eax, [rdi]
), будет вставлено так же дешево, как если бы мы добавили movzx eax, al
в машинный код. (Если все старшие байты RAX равны нулю, слияние или расширение эквивалентны.)
Хасуэлл и позже (а может и IvB), и все остальные поставщики x86, и маломощные процессоры от Intel типа Silvermont-семейства: никакого частичного переименования регистров. (Кроме AH/BH/CH/DH в семействе Intel SnB). Последнему процессору нет в этой категории почти десять лет, а последнему процессору с серьезными нарушениями (семейство P6) более десяти лет.
mov al, [rdi]
отстой: ложная зависимость и слияние стоит ALU uop в серверной части. Таким образом, это дополнительная задержка загрузки на критическом пути через все, что хранит операнд памяти.
Чтение EAX после записи AL имеет нулевой штраф; это вовсе не особый случай; слияние произошло, когда вы написали AL.
Код GCC представляет собой разумный компромисс между Core2/Nehalem и современными процессорами: загружайте с помощью movzx
, чтобы избежать ложной записи частичной регистрации. И последний movzx
, чтобы избежать задержки частичной регистрации в вызывающем объекте.
Но если это произойдет, современный Intel может меньше навредить, выбрав EDX или ECX в качестве временных, поскольку Intel может выполнять mov
-устранение с нулевой задержкой в movzx r32, r8
, но не в том же регистре. Он по-прежнему стоит внешнего интерфейса, поэтому он не бесплатен для пропускной способности, а только для задержки и внутренних портов. Это постоянная пропущенная оптимизация; Я не думаю, что GCC или clang знают, что нужно искать; например, они обычно расширяют до нуля 32-> 64 с помощью mov esi,esi
в аргументе функции.
movzx edx, byte ptr [rdi]
add dl, [rsi]
movzx eax, dl # mov-elimination possible on IvB and later (except Ice Lake with updated microcode which breaks mov-elim).
Если вы оптимизируете специально для Core2/Nehalem, вы должны сделать это:
xor eax, eax # off the critical path, avoids partial-reg stalls for later reads of EAX after writing AL
mov al, [rdi]
add al, [rsi]
Это неплохо на более поздних процессорах, хотя mov al, [rdi]
по-прежнему будет микро-плавкой загрузкой + ALU uop, поэтому он имеет дополнительную задержку загрузки и занимает дополнительный слот в планировщике и цикл на внутреннем порту выполнения. Таким образом, 3 внутренних мопов, по сравнению с 2 в IvB, а позже с исключенными movzx
, если вы выберете разные регистры.
Решение GCC использовать movzx
из-за Core2/Nehalem на данный момент очень консервативно; вероятно, -mtune=generic
в GCC12 не следует заботиться о стойлах частичного регистра семейства P6, поскольку этим процессорам уже более десяти лет. Особенно в 64-битном коде, где наихудшим случаем является Core2/Nehalem, а не даже более длительные зависания без слияния uop на более раннем семействе P6. (Кроме того, 64-битный код с большей вероятностью будет выполняться на новых процессорах; один из вариантов использования -m32
— создание кода для старых 32-битных процессоров.)
Это вполне может быть преднамеренный выбор настройки, который нуждается в обновлении. Это определенно пропущенная оптимизация с -march
/ -mtune=
k8
через znver3
, или silvermont
-семейство, или sandybridge
или новее.
(Также обратите внимание, что некоторые варианты, которые должен отличаются в зависимости от настройки -mtune
, на самом деле не различаются. GCC всегда делает некоторые вещи одним способом, и добавление хуков, чтобы отличать его на основе флага настройки, не было сделано. Clang — это таким же образом, например, -mtune=core2
все еще не знает, как избежать киосков с частичным регистром!)
лязг обычно живет опасной записью частичных регистров и иным образом игнорирует ложные зависимости, когда они явно не переносятся в цикле в одной функции (которая можно укусить за задницу). Это может сохранить целую инструкцию, когда она пропускает xor-zero, но экономия всего 1 байта в целом кажется нецелесообразной. Это ложная зависимость и означает, что mov load декодирует для загрузки + объединения ALU uops (для слияния нового младшего байта в существующий 64-битный регистр).
Похоже, clang просто сделал свою обычную работу по загрузке 8-битных значений в 8-битные регистры, игнорируя movzx
, а затем понял, что в конце нужно расширить результат до нуля.
Был бы полезен проход оптимизации, ищущий возможность свернуть нулевое расширение (после узкой математики) в более раннюю загрузку. И/или иным образом ищите способы доказать, что значения уже расширены до нуля, если это не так.
Наверное, в целом лучше начинать делать узкие нагрузки с movzx
, чтобы это было более нормально.
Возможно, вы захотите сообщить об ошибке пропущенной оптимизации, особенно для clang. Их генератор кода уже является огромным средним пальцем по отношению к семейству P6 большую часть времени с использованием частичного регистра, поэтому они, вероятно, будут заинтересованы в попытке создать версию с двумя инструкциями. https://github.com/llvm/llvm-project/issues
Также https://gcc.gnu.org/bugzilla/enter_bug.cgi?product=gcc (используйте ключевое слово miss-optimization для ошибок GCC. Не стесняйтесь ссылаться на этот пост о переполнении стека и/или цитировать любые мои комментарии, если хотите, а также ссылку на Godbolt. Разработчики GCC предпочитают синтаксис AT&T для обсуждения x86/ ошибки.)
Смотрите также:
movzx eax, dl
, и что слияние AH было бесплатным; см. мои вопросы и ответы о HSW / SKL Но руководство Агнера точно подходит для более ранних процессоров.)xor eax,eax
устанавливает какой-то внутренний флаг EAX=AL.I have 2 more examples here: https://godbolt.org/z/z45xr4hq1 GCC generates both sensible and strange movzx, and Clang's use of mov and movzx just makes no sense to me.
mov ecx, edx
нулевое расширение clang с 32 до 64 вместо 8 до 64 связано с тем, что оно зависит от неофициального расширения соглашения о вызовах x86-64 SysV, которое узкие аргументы расширены до 32-битных. Процессоры AMD Zen могут выполнять удаление перемещения для mov ecx, edx
, но не для movzx
-байт, так что это на самом деле более эффективно, а также экономит размер кода.
(GCC и clang создают вызывающих абонентов, которые уважают эту неофициальную функцию соглашения о вызовах, но только clang создает вызываемых абонентов, которые зависят от него. ICC не делает ни того, ни другого, поэтому он не совместим с ABI с clang.)
Расширение до intptr_t
, конечно, необходимо для всех более узких аргументов, если вы собираетесь индексировать массив с ним. (В абстрактных терминах C это всего лишь часть использования значения для математики указателя). Высокий мусор разрешен, по крайней мере, в старших 32 битах 64-битного регистра.
Спасибо за такой подробный ответ! Я пишу отчеты для Clang и GCC, и я буду ссылаться на этот пост.
@PeterCordes, что вы подразумеваете под «mov al, [rdi]
по-прежнему будет загрузка микропредохранителя + ALU uop, поэтому у него есть дополнительная задержка загрузки». Имеет ли ALU с микрослиянием более высокую задержку, чем явная загрузка + ALU?
@Noah «mov al, [rdi]
отстой. ложная зависимость, и для слияния требуется ALU uop в серверной части». Это стоит дополнительной операции ALU по сравнению с movzx, даже если зависимость от старого значения RAX была очищена.
@Noah: Нет, но movzx eax, byte ptr [rdi]
вообще не имеет ALU uop, EAX готов прямо из порта загрузки, без ALU uop как части цепочки отложений.
Я подумал, что clang предполагал что-то подобное, приятно знать, что у этого есть преимущества. Первоначально я имел в виду, что я не понимаю, почему clang использует mov r8 m
, а затем бесполезный / предотвратимый movzx.
@Hintro: Да, это просто плохой код, за исключением семейства P6, где вы хотите закончить с movzx
или начать с xor
-0, чтобы избежать штрафов для читателя ретвала EAX. Как я уже сказал в своем ответе, я предполагаю, что clang попадает туда, выполняя свою обычную загрузку байтов без movzx
, а затем имеет значение, требующее нулевого расширения, поэтому он делает это. Не рассматривая другие возможности. Компиляторы — это не ИИ, они могут использовать только те стратегии, которые люди сказали им искать, более или менее.
@Hintro: Чтобы сократить время компиляции, они в основном ограничиваются алгоритмами O (n ^ 2) в худшем случае, не рассматривая все возможные последовательности, чтобы найти ту, которая дает желаемые результаты с минимальными затратами. Это то, что делает «супероптимизатор», и он эффективен для последовательностей, возможно, из 4 инструкций или меньше. Но только в том случае, если у вас есть подходящая модель затрат, в данном случае учитывающая эффекты частичного регистра.
@PeterCordes Я думаю, вы правы в том, как clang сгенерировал эту последовательность, github.com/llvm/llvm-project/issues/…
@PeterCordes У меня есть еще один вопрос о коде GCC для add2bytes: придется ли add al, BYTE PTR [rdi]
ждать предыдущего movzx eax, BYTE PTR [rsi]
? Я думаю, что загрузка uop может происходить независимо, а затем добавление произойдет, когда загрузка и RAX будут готовы?
@Hintro: Верно, микрофьюжн есть только во внешнем интерфейсе и ROB. Во внутреннем планировщике загрузка и добавление являются двумя отдельными операциями, и загрузка может начаться, как только rsi
будет готов. И ALU add
может запуститься, как только оба его входа будут готовы.
В настоящее время я использую обходные пути встроенной сборки для своего кода, поэтому я также разместил аналогичные для примеров. Для примера foo
я также изменил входные данные на более подходящие типы.
Возможно связано: stackoverflow.com/questions/43491737/…