Ответ 1
Версия TL: DR: gcc является наиболее надежной среди всех x86-систем, избегая ложных зависимостей или лишних мопов. Ни один из них не является оптимальным; загрузка обоих байтов одной загрузкой должна быть еще лучше.
2 ключевых момента здесь:
-
Мэйнстримовые компиляторы заботятся только о неупорядоченных харчах x86 для их настройки по умолчанию для выбора инструкций и планирования. Все x86-выпуски, которые в настоящее время продаются, выполняют внеочередное выполнение с переименованием регистров (по крайней мере для полных регистров, таких как RAX).
Никакие упорядоченные uarches по-прежнему не имеют отношения к
tune=generic
. (Более старый Xeon Phi, Knight 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. Инструкции только для записи в регистры с низким 8/низким 16, такие как al
или ax
, не доступны только для записи, поскольку микроархитектура обеспокоена.)
На семействе P6 (от PPro до Nehalem) и Sandybridge (первое поколение семейства Sandybridge), код лязга прекрасно работает. Переименование регистров делает пары загрузки/хранения полностью независимыми друг от друга, как если бы они использовали разные архитектурные регистры.
На всех других уровнях код Clang потенциально опасен. Если RAX был целью некоторой более ранней загрузки кэша в вызывающей стороне или какой-либо другой длинной цепочке зависимостей, этот асм сделал бы хранилища зависимыми от этой другой dep-цепочки, связав их вместе и исключив возможность для ЦП найти 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 -zero, на Intel и AMD с Zen. Единственная дополнительная стоимость составляет 1 байт размера кода. (AMD до Zen имеет 1 цикл дополнительной задержки для загрузок movzx, и, очевидно, они должны работать как на ALU, так и на порте загрузки. Выполнение знака/нулевого расширения или широковещательная передача как часть загрузки без дополнительной задержки является современной Кстати, хотя.)
gcc довольно фанатичен в отношении нарушения ложных зависимостей, например, pxor xmm0,xmm0
cvtsi2ss/sd xmm0, eax
перед cvtsi2ss/sd xmm0, eax
, потому что плохо спроектированный набор команд Intel сливается с низким qword целевого регистра XMM. (Недальновидный дизайн для PIII, в котором 128-битные регистры хранятся в виде 2-х 64-битных половин, поэтому инструкции по преобразованию int-> FP потребовали бы дополнительного повышения на PIII, чтобы также обнулить верхнюю половину, если бы Intel разработала его с будущими процессорами в уме.)
Проблема обычно не в одной функции, а в том, что когда эти ложные зависимости в конечном итоге создают цепочку зависимостей, переносимую циклом через вызов /ret в разных функциях, вы можете неожиданно получить большое замедление.
Например, пропускная способность хранилища данных составляет всего 1 за такт (на всех текущих x86-архивах), поэтому для 2 загрузок + 2 хранилищ уже требуется как минимум 2 такта.
Однако если структура разбивается по границе строки кэша, и первая загрузка пропускается, но 2-е попадания, избегание ложного удаления позволило бы 2-му хранилищу записать данные в буфер хранилища до того, как будет завершена первая потеря кэша. Это позволило бы нагрузкам на это ядро читать из out2
через пересылку магазина. (Правила строгого упорядочения памяти x86 препятствуют тому, чтобы позднее хранилище стало глобально видимым, фиксируя буфер хранилища перед хранилищем out1
, но пересылка хранилища в ядре/потоке все еще работает.)
-
cmp/setcc
: MSVC/ICC просто тупые
Одним из преимуществ здесь является то, что помещение значения в ZF позволяет избежать каких-либо частичных регистров, но movzx
- лучший способ избежать этого.
Я почти уверен, что MS x64 ABI согласен с x86-64 System V ABI, что bool
в памяти гарантированно равен 0 или 1, а не 0/non -zero.
В абстрактной машине C++ x == true
должно быть таким же, как x
для bool x
, поэтому (если реализация не использует другие правила представления объектов в структурах по сравнению с extern bool
), он всегда может просто скопировать объект представление (т.е. байт).
Если реализация собиралась использовать однобайтовое 0/non-0 (вместо 0/1) представление объекта для bool
, ей нужно было бы cmp byte ptr [rcx], 0
чтобы реализовать логическое выражение в (int)(x == true)
, но здесь вы назначаете другому bool
чтобы он мог просто копировать. И мы знаем, что это не логическое значение 0/non -zero, потому что оно сравнивается с 1
. Я не думаю, что он намеренно out2 = in.in2
от недопустимых значений bool
, иначе почему бы не сделать это для out2 = in.in2
?
Это выглядит как пропущенная оптимизация. Компиляторы, как правило, не такие крутые в bool
. Логические значения как 8-битные в компиляторах. Операции на них неэффективны? , Некоторые лучше, чем другие.
MSVC setcc
непосредственно в память - это неплохо, но cmp + setcc - это 2 лишних меру ALU, которые не должны были произойти. По-видимому, на Ryzen, setcc m8
составляет 1 моп, но один на 2 такта пропускной способности. Так странно. Может быть, даже опечатка от Агнера? (https://agner.org/optimize/). На Steamroller это 1 моп /1 за такт.
На Intel, setcc m8
- это 2 setcc m8
доменом и 1 на тактовую пропускную способность, как и следовало ожидать.
- ICC xor -zero перед сетцем
Я не уверен, есть ли неявное преобразование в int
где-то здесь, в абстрактной машине ISO C++, или если ==
определено для операндов bool
.
Но в любом случае, если вы собираетесь setcc
в регистр, неплохо было бы сначала xor -zero сделать его по той же причине: movzx eax,mem
лучше, чем mov al,mem
. Даже если вам не нужен результат, расширенный с нуля до 32-битного.
Это, вероятно, постоянная последовательность ICC для создания логического целого числа из результата сравнения.
Не имеет смысла использовать xor
-zero/cmp/setcc для сравнения, но mov al, [m8]
для не сравнения. Xor -zero является прямым эквивалентом использования загрузки movzx
для movzx
ложной зависимости.
ICC отлично подходит для автоматической векторизации (например, он может автоматически векторизовать цикл поиска, например, как while(*ptr++ != 0){}
то время как gcc/clang может только автоматически while(*ptr++ != 0){}
с количеством отключений, которое известно до первая итерация). Но 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 -zero ing также пишет флаги).
не проходя через
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
с этим, - это разделение строки кэша на нагрузку (если in.in2
- это первый байт нового залога кэша). Это может занять дополнительные 10 циклов. Или на pre-Skylake, если он также разделен через границу 4k, штраф может составить 100 циклов дополнительной задержки. Но кроме этого, x86 имеет эффективные невыровненные нагрузки, и обычно выгодно объединять узкие загрузки/хранилища для сохранения мопов. (gcc7 и более поздние версии обычно делают это при инициализации нескольких членов структуры даже в тех случаях, когда он не может знать, что он не пересечет границу строки кэша.)
Компилятор должен быть в состоянии доказать, что In &in
не может использовать псевдоним extern bool out1, out2
, extern bool out1, out2
, потому что они имеют статическое хранилище и разные типы.
Если бы у вас было 2 указателя на bool
, вы бы не знали (без bool *__restrict out1
), что они не указывают на члены объекта In
. Но static bool out2
не может использовать псевдонимы членов статического объекта In
. Тогда было бы небезопасно читать in2
перед записью out1
, если вы сначала не проверили на совпадение.