Копирование bool из параметра в глобальный - вывод компиляторов сравнения

Полностью зная, что эти полностью искусственные тесты не имеют большого значения, я, тем не менее, немного удивлен несколькими способами, которые компиляторы "большой четверки" выбрали для создания тривиального фрагмента.

struct In {
    bool in1;
    bool in2;
};

void foo(In &in) {
    extern bool out1;
    extern bool out2;
    out1 = (in.in1 == true);
    out2 = in.in2;
}

Примечание: все компиляторы установлены в режиме x64 с наивысшим "общим назначением" (= не указана конкретная архитектура процессора) "оптимизация по скорости"; Вы можете увидеть результаты самостоятельно/поиграть с ними на https://gcc.godbolt.org/z/K_i8h9)


Clang 6 с -O3, по-видимому, дает наиболее простой результат:

foo(In&):                             # @foo(In&)
        mov     al, byte ptr [rdi]
        mov     byte ptr [rip + out1], al
        mov     al, byte ptr [rdi + 1]
        mov     byte ptr [rip + out2], al
        ret

В стандартной программе-совместимый C++ на == true сравнение является излишним, так что оба задания становятся прямыми копьями из одного места памяти в другую, не проходя через al, как там нет памяти для памяти mov.

Однако, поскольку здесь нет никакого давления в регистре, я ожидал, что он будет использовать два разных регистра (чтобы полностью избежать ложных цепочек зависимостей между двумя присваиваниями), возможно, сначала начав все чтения, а затем выполнив все записи после, чтобы помочь инструкции -уровневый параллелизм; этот вид оптимизации полностью устарел с недавними процессорами из-за переименования регистров и агрессивно вышедших из строя процессоров? (подробнее об этом позже)


GCC 8.2 с -O3 делает почти то же самое, но с изюминкой:

foo(In&):
        movzx   eax, BYTE PTR [rdi]
        mov     BYTE PTR out1[rip], al
        movzx   eax, BYTE PTR [rdi+1]
        mov     BYTE PTR out2[rip], al
        ret

Вместо простого mov к "маленькому" регистру, он делает movzx для полного eax. Зачем? Является ли это полным сбросом состояния eax и подрегистров в переименователе регистров, чтобы избежать частичных остановок регистров?


MSVC 19 с /O2 добавляет еще одну причуду:

in$ = 8
void foo(In & __ptr64) PROC                ; foo, COMDAT
        cmp     BYTE PTR [rcx], 1
        sete    BYTE PTR bool out1         ; out1
        movzx   eax, BYTE PTR [rcx+1]
        mov     BYTE PTR bool out2, al     ; out2
        ret     0
void foo(In & __ptr64) ENDP                ; foo

Помимо другого соглашения о вызовах, здесь второе назначение почти одинаково.

Однако сравнение в первом присваивании фактически выполняется (что интересно, используя как cmp и sete с операндами памяти, так что вы можете сказать, что промежуточный регистр - это FLAGS).

  • Является ли этот V C++ явным образом безопасным (программист попросил об этом, может, он знает что-то, чего я не знаю об этом bool) или это связано с некоторыми известными внутренними ограничениями - например, bool рассматривается как обычный байт без особых свойств сразу после интерфейса?
  • Поскольку это не "настоящая" ветвь (путь кода не изменяется в результате выполнения cmp), я ожидаю, что это не будет стоить так дорого, особенно по сравнению с доступом к памяти. Насколько затратна эта пропущенная оптимизация?

Наконец, ICC 18 с -O3 является самым странным из всех:

foo(In&):
        xor       eax, eax                                      #9.5
        cmp       BYTE PTR [rdi], 1                             #9.5
        mov       dl, BYTE PTR [1+rdi]                          #10.12
        sete      al                                            #9.5
        mov       BYTE PTR out1[rip], al                        #9.5
        mov       BYTE PTR out2[rip], dl                        #10.5
        ret                                                     #11.1
  • Первое назначение делает сравнение, точно так, как в V C++ коде, но sete проходит через al вместо того, чтобы прямо в память; есть ли причина предпочитать это?
  • Все чтения "запускаются", прежде чем что-либо делать с результатами - так что этот вид чередования все еще имеет значение?
  • Почему eax обнуляется в начале функции? Частичный регистр снова глохнет? Но тогда dl не получает это лечение...

Ради интереса я попытался удалить == true, а теперь ICC

foo(In&):
        mov       al, BYTE PTR [rdi]                            #9.13
        mov       dl, BYTE PTR [1+rdi]                          #10.12
        mov       BYTE PTR out1[rip], al                        #9.5
        mov       BYTE PTR out2[rip], dl                        #10.5
        ret                                                     #11.1

Таким образом, нет нуля из eax, но по-прежнему с использованием двух регистров и "сначала начать чтение параллельно, потом использовать все результаты".

  • Что такого особенного в sete которая заставляет ICC думать, что стоит обнулить eax раньше?
  • Правильно ли, в конце концов, ICC переупорядочивать операции чтения/записи подобным образом, или очевидно более небрежный подход других компиляторов в настоящее время выполняет то же самое?

Ответы

Ответ 1

Версия TL: DR: gcc является наиболее надежной среди всех x86-систем, избегая ложных зависимостей или лишних мопов. Ни один из них не является оптимальным; загрузка обоих байтов одной загрузкой должна быть еще лучше.

2 ключевых момента здесь:

  • Мэйнстримовые компиляторы заботятся только о неупорядоченных харчах x86 для их настройки по умолчанию для выбора инструкций и планирования. Все x86-выпуски, которые в настоящее время продаются, выполняют внеочередное выполнение с переименованием регистров (по крайней мере для полных регистров, таких как RAX).

    Никакие упорядоченные uarches по-прежнему не имеют отношения к tune=generic. (Более старый Xeon Phi, Knight Corner, использовал модифицированные ядра на основе процессоров на базе Pentium P54C, и система Atom на заказ могла бы все еще существовать, но сейчас она тоже устарела. В этом случае было бы важно сделать магазины после загружает, чтобы позволить параллелизм памяти в нагрузках.)

  • 8- и 16-битные регистры с неполными значениями проблематичны и могут привести к ложным зависимостям. Почему GCC не использует частичные регистры? объясняет различные варианты поведения для различных харчей x86.


  1. частичное переименование регистра, чтобы избежать ложных зависимостей:

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-байтовый код операции).

  1. Почему 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, но пересылка хранилища в ядре/потоке все еще работает.)


  1. 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 на тактовую пропускную способность, как и следовало ожидать.

  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.

  1. все считывания "начинаются", прежде чем что-либо делать с результатами - так что этот вид чередования все еще имеет значение?

Это не плохая вещь. Устранение неоднозначности памяти обычно позволяет нагрузкам после магазинов работать в любом случае рано. Современные процессоры 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, если вы сначала не проверили на совпадение.

Ответ 2

Я запустил все коды в цикле на Хасуэлле. На следующем графике показано время выполнения каждого из 1 миллиарда итераций в трех случаях:

  • В начале каждой итерации есть mov rax, qword [rdi+64]. Это потенциально создает зависимость ложного регистра (так называемый dep в графе).
  • В начале каждой итерации есть add eax, eax (называемый fulldep на графике). Это создает зависимую от цикла зависимость и ложную зависимость. Смотрите также изображение ниже для иллюстрации всех истинных и ложных зависимостей add eax, eax, что также объясняет, почему он сериализует выполнение в обоих направлениях.
  • Только частичная зависимость регистров (называемая nodep в графе, которая не nodep ложной зависимости). Таким образом, этот случай имеет одну меньшую инструкцию для каждой итерации по сравнению с предыдущей.

В обоих случаях на каждой итерации обращаются к тем же ячейкам памяти. Например, проверенный мной Clang-код выглядит следующим образом:

mov     al, byte [rdi]
mov     byte [rsi + 4], al
mov     al, byte [rdi + 1]
mov     byte [rsi + 8], al

Это помещается в петлю, где rdi и rsi никогда не меняются. Не существует псевдонимов памяти. Результаты ясно показывают, что частичные зависимости регистров приводят к замедлению на 7.5% на Clang. Питер, MSVC и gcc - все явные победители с точки зрения абсолютной производительности. Также обратите внимание, что во втором случае код Peter работает немного лучше (2.02c на итерацию для gcc и msvc, 2.04c для icc, но только 2.00c для Peter). Другой возможной метрикой сравнения является размер кода.

enter image description here

enter image description here