TL: DR: версия gcc является самой надежной среди всех x86-версий, избегая ложных зависимостей или лишних мопов. Ни одна из них не является оптимальной; загрузка обоих байтов одной загрузкой должна быть еще лучше.
2 ключевых момента здесь:
Основные компиляторы заботятся только о неупорядоченных харчах x86 для их настройки по умолчанию для выбора команд и планирования. Все x86-ценные бумаги, которые в настоящее время продаются, выполняют внеочередное выполнение с переименованием регистров (для full регистров, как минимум, RAX).
Никакие порядковые уарчи по-прежнему не актуальны для tune=generic
. (Более старый Xeon Phi, Knight's Corner, использовал модифицированные ядра на заказ на базе Pentium P54C, и система Atom на заказ могла бы все еще существовать, но это тоже устарело. В таком случае было бы важно сделать магазины после загружает, чтобы позволить параллелизм памяти в нагрузках.)
8 и 16-битные Частичные регистры проблематичны и могут привести к ложным зависимостям. Почему в GCC не используются частичные регистры? объясняет различные варианты поведения для различных харчей x86.
- частичное переименование регистра во избежание ложных зависимостей:
Intel до IvyBridge переименовывает AL отдельно от RAX (семейство P6 и сам SnB, но не позднее семейство SnB). На всех других uarches (включая Haswell / Skylake, все AMD и Silvermont / KNL) запись AL сливается с RAX . Для получения дополнительной информации о современных Intel (HSW и более поздних версиях) против семейства P6 и Sandybridge первого поколения см. Этот раздел вопросов и ответов: Как именно работают частичные регистры на Haswell / Skylake? Написание AL, похоже, ложно зависит от RAX, а AH несовместимо .
В Haswell / Skylake mov al, [rdi]
декодирует в микроплавкий ALU + load uop, который объединяет результат загрузки в RAX. (Это хорошо для слияния битовых полей, вместо того, чтобы иметь дополнительные затраты на интерфейсную часть для вставки более позднего слияния при чтении полного регистра).
Он работает так же, как add al, [rdi]
или add rax, [rdi]
. (Это всего лишь 8-битная загрузка, но она зависит от полной ширины старого значения в RAX. Инструкции только для записи в регистры low-8 / low-16, такие как al
или ax
, не записываются только в отношении микроархитектуры.)
На семействе P6 (от PPro до Nehalem) и Sandybridge (первое поколение семейства Sandybridge) код Clang в порядке. Переименование регистров делает пары загрузки / хранения полностью независимыми друг от друга, как если бы они использовали разные архитектурные регистры.
На всех других uarches код Clang потенциально опасен. Если RAX был целью некоторой более ранней загрузки кэша в вызывающей программе или какой-либо другой длинной цепочке зависимостей, этот асм сделал бы хранилища зависимыми на этом другом депе, соединяя их вместе и исключая возможность для ЦП найти ILP.
нагрузки по-прежнему независимы, потому что нагрузки отделены от объединения и могут произойти, как только адрес нагрузки rdi
будет известен в ядре неработоспособности. Адрес хранилища также известен, поэтому мопы с адресом хранилища могут выполняться (поэтому более поздние загрузки / хранилища могут проверять наличие совпадений), но маны хранилища данных застряли в ожидании слияния. (Магазины в Intel - это всегда 2 отдельных мопа, но они могут слиться воедино во внешнем интерфейсе.)
Clang, кажется, не очень хорошо понимает частичные регистры и создает ложные задержки и штрафы за частичную регистрацию без причины иногда , даже если он не сохраняет размер кода с помощью узкого размера or al,dl
вместо or eax,edx
, например.
В этом случае он сохраняет байт размера кода на загрузку (movzx
имеет 2-байтовый код операции).
- Почему gcc использует
movzx eax, byte ptr [mem]
?
Запись EAX начинается с нуля до полного RAX, поэтому он всегда доступен только для записи без ложной зависимости от старого значения RAX на любом процессоре. Почему инструкции x86-64 для 32-разрядных регистров обнуляют верхнюю часть полного 64-разрядного регистра? .
movzx eax, m8/m16
обрабатывается исключительно в портах загрузки, а не какнагрузка + ALU-ноль-расширение, на Intel и AMD, начиная с Zen.Единственная дополнительная стоимость составляет 1 байт размера кода.(AMD до Zen имеет 1 цикл дополнительной задержки для загрузок movzx, и, очевидно, они должны работать как на ALU, так и на порте загрузки. Выполнение знака / нулевого расширения или широковещательная передача как часть загрузки без дополнительной задержки является современнойКстати,.
gcc довольно фанатично разбивает ложные зависимости, например, pxor xmm0,xmm0
до cvtsi2ss/sd xmm0, eax
, потому что плохо спроектированный набор команд Intel сливается с малым qword целевого регистра XMM.(Недальновидный дизайн для PIII, в котором 128-битные регистры хранятся в виде 2-х 64-битных половинок, поэтому инструкции по преобразованию int-> FP потребовали бы дополнительного повышения на PIII, чтобы также обнулить верхнюю половину, если бы Intel разработала его с будущими процессорами впомните.)
Проблема обычно не в одной функции, а в том, что когда эти ложные зависимости в конечном итоге создают цепочку зависимостей, переносимых в цикле по вызову / ретрансляции в разных функциях, вы можете неожиданно получить большое замедление.
Например, пропускная способность хранилища данных составляет всего 1 за такт (на всех текущих x86-арках), поэтому для 2 загрузок + 2 хранилищ уже требуется как минимум 2 такта.
Если структура разбитачерез границу строки кэша, однако, и первая загрузка пропускается, но 2-е попадание, избегая ложного удаления, позволило бы 2-му хранилищу записать данные в буфер хранилища до того, как будет завершена первая потеря кэша.Это позволило бы нагрузкам на этом ядре читать из out2
через пересылку из магазина.(Из-за строгих правил упорядочения памяти в x86 более позднее хранилище становится глобально видимым, если зафиксировать в буфере хранилища перед хранилищем значение out1
, но пересылка хранилища в ядре / потоке все еще работает.)
cmp/setcc
: MSVC / ICC просто тупеют* это лучший способ избежать этого. Я почти уверен, что MSI x64 ABI согласен с x86-64 System V ABI, что bool
в памяти гарантированно будет 0 или 1, а не 0 /ненулевой. В абстрактной машине C ++ x == true
должен быть таким же, как x
для bool x
, поэтому (если реализация не использовала другие правила представления объектов в структурах по сравнению с extern bool
), он всегда может просто скопировать объектное представление (т. Е. Байт). Если реализация собиралась использовать однобайтовое 0 / не-0 (вместо 0/1) объектное представление дляbool
, потребуется cmp byte ptr [rcx], 0
для реализации логического преобразования в (int)(x == true)
, но здесь вы назначаете другой bool
, чтобы он мог просто скопировать.И мы знаем, что это не логическое значение 0 / ненулевое, потому что оно сравнивается с 1
.Я не думаю, что он намеренно защищается от недопустимых значений bool
, иначе почему бы не сделать это для out2 = in.in2
? Это просто выглядит как упущенная оптимизация.Компиляторы вообще не круты на bool
в целом. Логические значения как 8-битные в компиляторах.Операции над ними неэффективны? .Некоторые из них лучше других. MSVC setcc
напрямую в память - это неплохо, но cmp + setcc - это 2 лишних меру ALU, которые не должны были происходить. По-видимому, в Ryzensetcc m8
- 1 моп, но один на 2 такта.Так странно.Может быть, даже опечатка от Агнера?(https://agner.org/optimize/). В Steamroller это 1 моп / 1 на такт. В Intel setcc m8
- 2 мопа с плавким доменом и 1 на тактовую пропускную способность, как и следовало ожидать. Обнуление xor в ICC перед setz Я не уверен, есть ли неявное преобразование в int
где-нибудь здесь, в абстрактной машине ISO C ++, или если ==
определено для bool
операндов. Но в любом случае, если высобираемся setcc
в регистр, неплохо было бы сначала xor-zero его по той же причине movzx eax,mem
лучше, чем mov al,mem
.Даже если вам не нужен результат, расширенный с нуля до 32-разрядного. Это, вероятно, стандартная последовательность ICC для создания логического целого числа из результата сравнения. Не имеет смысла использоватьxor
- ноль / cmp / setcc для сравнения, но mov al, [m8]
для не сравнения.Значение xor-zero является прямым эквивалентом использования movzx
нагрузки для устранения ложной зависимости. ICC отлично подходит для автоматической векторизации (например, он может автоматически векторизовать цикл поиска, например while(*ptr++ != 0){}
в то время как gcc / clang может только автоматически выполнять циклы vec с количеством отключений, которое известно до первой итерации). Но ICC не очень хорош для небольших микрооптимизаций, подобных этой ;у него часто есть вывод asm, который больше похож на источник (в ущерб), чем на gcc или clang. все чтения «начинаются», прежде чем что-либо делать с результатами - так что этот вид чередования все еще имеет значение? Это не плохо.Устранение неоднозначности памяти обычно позволяет нагрузкам после магазинов работать в любом случае рано.Современные процессоры x86 даже динамически предсказывают, когда нагрузка не будет перекрываться с ранее сохраненными хранилищами неизвестных адресов. Если адрес загрузки и хранения разделен точно на 4 Кб, они являются псевдонимом для процессоров Intel, и нагрузка ошибочно определяется какзависит от магазина. Перемещение грузов впереди магазинов определенно облегчает работу процессора;Делайте это, когда это возможно. Кроме того, интерфейсный модуль выдает упорядоченные упорядоченные элементы в неупорядоченную часть ядра, поэтому при первом размещении нагрузок можно запустить второй, возможно, цикл раньше.Нет смысла делать первый магазин сразу же;ему придется ждать результата загрузки, прежде чем он сможет выполняться. Повторное использование одного и того же регистра уменьшает давление в регистре.GCC любит избегать давления регистратора все время, даже когда его нет, как в этой не встроенной версии функции.По моему опыту, gcc склоняется к способам генерации кода, который в первую очередь создает меньшее давление в регистре, а не только ограничивает его использование регистром, когда существует фактическое давление в регистре после встраивания. Так что вместо того, чтобы иметь2 способа сделать что-то, у gcc иногда есть только метод «меньше регистрировать давление», который он использует, даже когда он не встроен.Например, GCC почти всегда использует setcc al
/ movzx eax,al
для логического преобразования, но недавние изменения позволили ему использовать xor eax,eax
/ set-flags / setcc al
для отключения расширения нулякритический путь, когда есть свободный регистр, который может быть обнулен перед тем, что устанавливает флаги.(Обнуление xor также записывает флаги). , проходящих через al
, так как нет памяти в памяти mov
. Не стоит использовать для одного-байтовые копии, в любом случае.Одна из возможных (но неоптимальных) реализаций: foo(In &):
mov rsi, rdi
lea rdi, [rip+out1]
movsb # read in1
lea rdi, [rip+out2]
movsb # read in2
Реализация, которая, вероятно, лучше, чем у всех обнаруженных компиляторов: foo(In &):
movzx eax, word ptr [rdi] # AH:AL = in2:in1
mov [rip+out1], al
mov [rip+out2], ah
ret
Чтение AH может иметь дополнительный цикл задержки, но это здорово для пропускной способности и размера кода.Если вы заботитесь о задержке, в первую очередь избегайте сохранения / перезагрузки и используйте регистры.(Включив эту функцию). Единственная микроархитектурная опасность, связанная с этим, - это разделение строки кэша на нагрузку (если in.in2
- это первый байт нового залога кэша). Это может занять дополнительные 10 циклов. Или на pre-Skylake, если он также разделен через границу 4k, штраф может составить 100 циклов дополнительной задержки. Но кроме этого, x86 имеет эффективные невыровненные загрузки, и обычно выгодно объединять узкие загрузки / хранилища для сохранения мопов. (gcc7 и более поздние версии обычно делают это при инициализации нескольких членов структуры даже в тех случаях, когда он не может знать, что он не пересечет границу строки кэша.)
Компилятор должен быть в состоянии доказать, что In &in
не может иметь псевдоним extern bool out1, out2
, потому что они имеют статическое хранилище и разные типы.
Если бы у вас было всего 2 указателей * от 1210 * до bool
, вы бы не знали (без bool *__restrict out1
), что они не указывают на членов объекта In
. Но статический bool out2
не может использовать псевдонимы членов статического In
объекта. Тогда было бы небезопасно читать in2
перед написанием out1
, если только вы сначала не проверили на совпадение.