Странное использование movzx Clang и GCC

Я знаю, что 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

Возможно связано: stackoverflow.com/questions/43491737/…

Jakob Stark 12.07.2022 10:37

Что вы называете "разорвать зависимость"?

Yves Daoust 12.07.2022 10:38

У @YvesDaoust stackoverflow.com/a/43910889/14730360 есть пример того, как movzx разрывает зависимость и помогает выполнению OoO. Однако я не думаю, что movzx делает это здесь.

Hintro 12.07.2022 10:43

@YvesDaoust: Современные процессоры x86 имеют гораздо больше регистров, чем регистр имена. Вот почему физические регистры постоянно имеют значение переименован. Это сложный процесс. Одна важная проблема заключается в том, что он должен быть невидимым для кода. Это имеет значение при использовании частичных имен регистров, таких как al. Этот должен сохраняет старшие биты, останавливая переименование. Но movzx выполняет нулевое расширение, поэтому позволяет переименовывать.

MSalters 12.07.2022 10:45

Обновлен мой ответ с разделом о вашей ссылке Godbolt a[a[i] | a[j]], показывающим clang, ожидающим, что вызывающий абонент уже расширил i и j до 32-бит.

Peter Cordes 13.07.2022 06:42
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
53
5
2 625
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Финал MOVZX обусловлен тем фактом, что функция возвращает int, расширенный из байта. В версии clang это должен, но с gcc он лишний.

это не объясняет скомпилированную версию GCC в вопросе. Там eax уже было очищено в первой инструкции.

Jakob Stark 12.07.2022 10:45

@JakobStark: вы забываете о возможном переносе.

Yves Daoust 12.07.2022 10:46

Перенос может быть и фактически игнорируется.

Jakob Stark 12.07.2022 10:49

@YvesDaoust под переносом вы имеете в виду перенос, когда результат добавления больше 255? Я пробовал, и этого не происходит. Я имею в виду, что 8-битные инструкции в X86 не должны влиять на старшие биты, верно? Кстати, у меня также есть пример использования или в моей ссылке на Godbolt

Hintro 12.07.2022 10:51

@Hintro: ты прав, мой плохой. Затем clang должен делает расширение, а gcc делает это слишком часто.

Yves Daoust 12.07.2022 10:55

Я не понимаю этот ответ. Соглашение о возврате состоит в том, что 32 бита в EAX должны быть правильными. Не важно ни на йоту, как эти биты оказались правильными. Первый movzx в коде GCC обнуляет верхние 24 бита, и нет необходимости их «повторно обнулять».

MSalters 12.07.2022 10:55

8-битная инструкция добавлять складывает два 8-битных значения и сохраняет результат в младшем байте целевого регистра. О возможном переполнении и переносе сигнализируется с помощью флага переноса, нет — 9-го бита в регистре.

Jakob Stark 12.07.2022 10:55

Просто чтобы быть более явным. C++ использует абстрактную модель для описания того, как достигается результат ("int, расширенный из байта"), но не требует, чтобы реализация следовала этой абстрактной модели. Кроме того, C++ нисколько не заботится о ненаблюдаемом C++ состоянии ЦП, таком как регистры переноса. Это не часть абстрактной модели и, следовательно, все равно.

MSalters 12.07.2022 12:40

Этот ответ на самом деле больше не является неправильным, но не очень полезным. Я бы порекомендовал удалить, так как я думаю, что другие ответы теперь достаточно охватывают тот факт, что результатом должно быть усеченное добавление с нулевым расширением до 32-битного. (В версии clang неправда, что movzx должен быть там, это может быть при начальной загрузке. Или мы могли бы начать с xor eax,eax, чтобы добиться нулевого расширения другим способом; это было бы даже лучше для семейства P6, что позволяет нам закончить байтом add без штрафа за неполную регистрацию для читателя.)

Peter Cordes 13.07.2022 09:42

Бит лязга на самом деле кажется разумным. Вы получите неполный регистровый стойл, если будете писать в 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 12.07.2022 11:11

@Hintro: Только Sandybridge и более поздние версии действительно сделали слияние с частичным регистром дешевым. Core2 и Nehalem вставляют объединяющуюся uop, но только после остановки примерно на 3 цикла IIRC. SnB вставляет слияние uop только с ожидаемой начальной стоимостью. (Если это не uop, объединяющий AH, тогда он должен выдаваться сам по себе). А HSW даже low8 не переименовывает отдельно от полного рега, поэтому код clang (по-прежнему) опасно живет с ложной зависимостью от старого значения RAX, лишь бы сохранить 1 байт размера кода в загрузке.

Peter Cordes 12.07.2022 11:35

О, конечно, clang не заботится о старших 24-битных значениях выше al, но ЦП, который не выполняет частичные переименования, должен отслеживать эти 24-битные значения до тех пор, пока они не будут обнулены — две инструкции ниже.

MSalters 12.07.2022 11:42

@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 было бы неплохо.

Peter Cordes 12.07.2022 11:43

@Подсказка: вы можете сообщить об ошибке пропущенной оптимизации, особенно для clang. Их генератор кода уже является большим средним пальцем по отношению к семейству P6 большую часть времени с использованием частичного регистра, поэтому они, вероятно, будут заинтересованы в попытке создать версию с двумя инструкциями. github.com/llvm/llvm-проект/вопросы и gcc.gnu.org/bugzilla/enter_bug.cgi?product=gcc (используйте пропущенную оптимизацию ключевого слова для ошибок GCC. Не стесняйтесь ссылаться на этот пост о переполнении стека и / или цитировать любой из моих комментариев, если хотите.)

Peter Cordes 12.07.2022 11:58
Ответ принят как подходящий

Оба компилятора здесь плохо справляются, но код 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/ ошибки.)

Смотрите также:


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, и я буду ссылаться на этот пост.

Hintro 12.07.2022 22:02

@PeterCordes, что вы подразумеваете под «mov al, [rdi] по-прежнему будет загрузка микропредохранителя + ALU uop, поэтому у него есть дополнительная задержка загрузки». Имеет ли ALU с микрослиянием более высокую задержку, чем явная загрузка + ALU?

Noah 13.07.2022 04:06

@Noah «mov al, [rdi] отстой. ложная зависимость, и для слияния требуется ALU uop в серверной части». Это стоит дополнительной операции ALU по сравнению с movzx, даже если зависимость от старого значения RAX была очищена.

Hintro 13.07.2022 04:35

@Noah: Нет, но movzx eax, byte ptr [rdi] вообще не имеет ALU uop, EAX готов прямо из порта загрузки, без ALU uop как части цепочки отложений.

Peter Cordes 13.07.2022 05:00

Я подумал, что clang предполагал что-то подобное, приятно знать, что у этого есть преимущества. Первоначально я имел в виду, что я не понимаю, почему clang использует mov r8 m, а затем бесполезный / предотвратимый movzx.

Hintro 13.07.2022 09:26

@Hintro: Да, это просто плохой код, за исключением семейства P6, где вы хотите закончить с movzx или начать с xor-0, чтобы избежать штрафов для читателя ретвала EAX. Как я уже сказал в своем ответе, я предполагаю, что clang попадает туда, выполняя свою обычную загрузку байтов без movzx, а затем имеет значение, требующее нулевого расширения, поэтому он делает это. Не рассматривая другие возможности. Компиляторы — это не ИИ, они могут использовать только те стратегии, которые люди сказали им искать, более или менее.

Peter Cordes 13.07.2022 09:36

@Hintro: Чтобы сократить время компиляции, они в основном ограничиваются алгоритмами O (n ^ 2) в худшем случае, не рассматривая все возможные последовательности, чтобы найти ту, которая дает желаемые результаты с минимальными затратами. Это то, что делает «супероптимизатор», и он эффективен для последовательностей, возможно, из 4 инструкций или меньше. Но только в том случае, если у вас есть подходящая модель затрат, в данном случае учитывающая эффекты частичного регистра.

Peter Cordes 13.07.2022 09:37

@PeterCordes Я думаю, вы правы в том, как clang сгенерировал эту последовательность, github.com/llvm/llvm-project/issues/…

Hintro 13.07.2022 09:50

@PeterCordes У меня есть еще один вопрос о коде GCC для add2bytes: придется ли add al, BYTE PTR [rdi] ждать предыдущего movzx eax, BYTE PTR [rsi]? Я думаю, что загрузка uop может происходить независимо, а затем добавление произойдет, когда загрузка и RAX будут готовы?

Hintro 13.07.2022 22:36

@Hintro: Верно, микрофьюжн есть только во внешнем интерфейсе и ROB. Во внутреннем планировщике загрузка и добавление являются двумя отдельными операциями, и загрузка может начаться, как только rsi будет готов. И ALU add может запуститься, как только оба его входа будут готовы.

Peter Cordes 13.07.2022 23:05

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

Hintro 17.07.2022 07:50

Другие вопросы по теме

Моя процедура из другого модуля не возвращается правильно к основной процедуре из основного модуля после некоторых дополнительных нажатий
Показать расширения макросов в GNU Assembler - AS - листинге. Только предварительная обработка?
Может ли кто-нибудь помочь мне прочитать 64-битную консоль в 32-битной RISC-V?
Разбить логику цикла C for на то, что я могу реализовать в MIPS? (количество вхождений в массиве)
Я не знаю, почему при делении частное и остаток неверны
Какое соглашение о вызовах использует printf() в C?
«Нет необходимости освобождать стек в конце функции, когда кадр внутреннего стека не изменяется», но в этом случае он изменяется
Как сгенерировать прерывание клавиатуры в сборке 8086
Почему x86 не реализовал прямые инструкции сборки/процессора для обмена сообщениями между ядрами?
Возможно ли иметь переключатель/кейс с mips, используя JumpTable для непоследовательных опций?