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

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

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).

  • Этот VC ++ явно воспроизводит его безопасно (программист попросил об этом, может быть, онзнает что-то, что я не знаю об этом 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
  • Первое присваивание выполняет сравнение, точно так же, как в коде VC ++, но 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 правильным, в конце концов, переупорядочить операции чтения / записи таким образом, или очевидно более небрежный подход других компиляторов в настоящее время выполняет то же самое?

Ответы [ 2 ]

0 голосов
/ 14 сентября 2018

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

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

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

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

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

enter image description here

enter image description here

0 голосов
/ 14 сентября 2018

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

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

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

    Никакие порядковые уарчи по-прежнему не актуальны для tune=generic. (Более старый Xeon Phi, Knight's 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. Инструкции только для записи в регистры 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-байтовый код операции).

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