Почему clang не использует инструкции x86 для назначения памяти, когда я компилирую с отключенной оптимизацией?Они эффективны? - PullRequest
0 голосов
/ 27 января 2019

Я написал этот простой ассемблерный код, запустил его и посмотрел на область памяти, используя GDB:

    .text

.global _main

_main:
    pushq   %rbp
    movl    $5, -4(%rbp)
    addl    $6, -4(%rbp)
    popq    %rbp
    ret

Он добавляет 5 к 6 непосредственно в памяти, и, согласно GDB, это сработало.Таким образом, здесь выполняются математические операции непосредственно в памяти, а не в регистрах ЦП.

Теперь записать то же самое в C и собрать его в сборку получается так:

...  # clang output
    xorl    %eax, %eax
    movl    $0, -4(%rbp)
    movl    $5, -8(%rbp)
    movl    -8(%rbp), %ecx   # load a
    addl    $6, %ecx         # a += 6
    movl    %ecx, -8(%rbp)   # store a
....

Он перемещает их взарегистрироваться, прежде чем добавлять их вместе.

Так почему бы нам не добавить непосредственно в память?

Это медленнее? Если так, то почему добавление непосредственно в память дажеразрешено, почему ассемблер не жаловался на мой ассемблерный код в начале?

Редактировать: Вот код C для второго блока ассемблера, я отключил оптимизацию при компиляции.

#include <iostream>

int main(){
 int a = 5;
 a+=6; 
 return 0;
}

1 Ответ

0 голосов
/ 27 января 2019

Вы отключили оптимизацию, и вы удивились, что асм выглядит неэффективно?Ну, не надо. Вы попросили компилятор быстро скомпилировать : короткое время компиляции вместо короткого времени выполнения для сгенерированного двоичного файла.И с согласованностью режима отладки.

Да, GCC и clang будут использовать добавление по месту назначения памяти при настройке для современных процессоров x86 .Это эффективно, если вы не используете результат добавления в регистр.Очевидно, что ваш рукописный ассм имеет большую пропущенную оптимизацию.movl $5+6, -4(%rbp) будет гораздо более эффективным, потому что оба значения являются константами времени сборки, поэтому оставление дополнения до времени выполнения ужасно.Как и в случае с вашим антиоптимизированным выводом компилятора.

(Обновление: только что заметил, что ваш вывод компилятора включал xor %eax,%eax, так что это похоже на clang / LLVM, а не на gcc, как я изначально догадывался. Практически все в этом ответе примениморавнозначно лязгу, но gcc -O0 не ищет оптимизации глазка xor-zeroing на -O0, используя mov $0, %eax.)

Интересный факт: gcc -O0 будет фактически использовать addl $6, -4(%rbp) в вашемmain.


Вы уже знаете из написанного от руки асма, что добавление немедленного в память кодируется как инструкция x86 add , поэтому единственный вопрос заключается в том,Оптимизатор gcc / LLVM решает использовать его или нет.Но вы отключили оптимизацию.

Добавление к месту назначения памяти не выполняет вычисление «в памяти», центральный процессор должен загружать / добавлять / хранить .При этом он не мешает ни одному из архитектурных регистров, а просто отправляет 6 на DRAM, который будет добавлен туда.См. Также Может ли num ++ быть атомарным для 'int num'? для сведений о C и x86 asm целевого ADD с / без префикса lock, чтобы он выглядел атомарно.

Существуют исследования компьютерной архитектуры по размещению ALU в DRAM, поэтому вычисления могут выполняться параллельно, вместо того чтобы требовать передачи всех данных через шину памяти в ЦП для выполнения любых вычислений.Это становится все более узким местом, поскольку объемы памяти растут быстрее, чем пропускная способность памяти, а пропускная способность процессора (с широкими инструкциями SIMD) также растет быстрее, чем пропускная способность памяти.(Требование большей вычислительной интенсивности (объем работы ALU на нагрузку / хранилище) для ЦП, чтобы не зависать. Быстрые кеши помогают, но некоторые проблемы имеют большие рабочие наборы и для них трудно применить блокировку кеша. Быстрые кеши решают проблему чаще всеговремени.)

Но в нынешнем виде add $6, -4(%rbp) декодирует в нагрузку, добавляет и хранит мопы внутри вашего процессора .Загрузка использует внутренний временный пункт назначения, а не архитектурный регистр.

Современные процессоры x86 имеют некоторые скрытые внутренние логические регистры, которые многопользовательские инструкции могут использовать для временных объектов.Эти скрытые регистры переименовываются в физические регистры на этапе выпуска / переименования, поскольку они размещаются в некондиционном бэкэнде, но во входном интерфейсе (выход декодера, кэш uop, IDQ) мопы могут ссылаться только на«виртуальные» регистры, которые представляют логическое состояние машины.Таким образом, множественные мопы, в которые декодируются инструкции ALU назначения памяти, вероятно, используют скрытые регистры tmp.

Мы знаем, что они существуют для использования инструкциями микрокода / multi-uop: http://blog.stuffedcow.net/2013/05/measuring-rob-capacity/ вызывает их "дополнительные архитектурные регистры для внутреннего использования ».Они не являются архитектурными в смысле того, чтобы быть частью состояния машины x86, только в том смысле, что они являются логическими регистрами, которые таблица-регистр-распределение (RAT) должна отслеживать для переименования регистров в физический регистровый файл.Их значения не нужны между инструкциями x86, только для мопов в одной инструкции x86, особенно микрокодированных, таких как rep movsb (который проверяет размер и перекрытие и использует 16 или 32-байтовые загрузки / хранилища, если это возможно), нотакже для многопользовательской памяти + инструкции ALU.

Оригинал 8086 не вышел из строя или даже конвейерным.Он может просто загружаться прямо во вход ALU, а затем, когда ALU будет готов, сохранить результат. Ему не нужны временные «архитектурные» регистры в его регистровом файле, просто обычная буферизация между компонентами.Предположительно, так все работало до 486.Может быть, даже Pentium.


это медленнее?если это так, то почему добавление напрямую разрешено даже в память, почему ассемблер не жаловался на мой ассемблерный код в начале?

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

Современный x86 эволюционировал из 8086. Есть много медленных способов сделать что-то в современном x86 asm, но ни один из них не может быть запрещен без нарушения обратной совместимости.Например, инструкция enter была добавлена ​​еще в 186 году для поддержки вложенных процедур Паскаля, но сейчас она очень медленная.Инструкция loop существует с 8086 года, но она слишком медленная для того, чтобы компиляторы могли ее использовать, начиная с 486. Я думаю, может быть, 386. ( Почему инструкция цикла слишком медленная? Разве Intel не смогла реализовать ее эффективно? )

x86 - это абсолютно последняя архитектура, в которой вы можете подумать, что есть какая-то связь между разрешением и эффективностью. Он эволюционировал очень вдали от аппаратного обеспечения.ISA была разработана для.Но в целом это не так на любом большинстве ISA.например, некоторые реализации PowerPC (в частности, процессор Cell в PlayStation 3) имеют медленные микрокодированные сдвиги с переменным счетом, но эта инструкция является частью ISA PowerPC, поэтому вообще не поддерживать инструкцию будет очень болезненно и не стоит используя несколько инструкций вместо того, чтобы позволить микрокоду делать это, вне горячих циклов.

Вы могли бы написать ассемблер, который отказывался использовать или предупреждал об известной медленной инструкции, такой как enter илиloop, но иногда вы оптимизируете размер, а не скорость, а затем медленно, но полезны небольшие инструкции, такие как loop .(https://codegolf.stackexchange.com/questions/132981/tips-for-golfing-in-x86-x64-machine-code, и посмотрите ответы машинного кода x86, как мой цикл GCD в 8 байтах 32-битного кода x86 , использующий множество небольших, но медленных инструкций, таких как 3-битный 1-байтовый xchg eax, r32 и даже inc / loop в качестве 3-байтовой альтернативы 4-байтовой test ecx,ecx / jnz).Оптимизация по размеру кода полезна в реальной жизни для загрузочных секторов или для забавных вещей, таких как 512-байтовые или 4k "демки", которые рисуют классную графику и воспроизводят звук только в крошечных количествах исполняемых файлов.Или для кода, который выполняется только один раз при запуске, чем меньше размер файла, тем лучше.Или же выполняется редко в течение срока службы программы, меньший объем I-кэша лучше, чем удаление большого объема кэша (и страдание внешнего интерфейса, ожидающего выборки кода).Это может перевесить максимальную эффективность, когда байты инструкций действительно поступают в CPU и декодируются.Особенно, если разница небольшая по сравнению с сохранением размера кода.

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


Предотвращение замедленийтребует одновременного просмотра более 1 инструкции

Большинство способов сделать код медленным - это не просто плохие инструкции, просто медленная комбинация. Проверка производительностиошибки обычно требуют одновременного просмотра более 1 инструкции.

например, этот код приведет к частичной остановке регистрации на процессорах семейства Intel P6 :

mov  ah, 1
add  eax, 123

Любая из этих инструкций потенциально может быть частью эффективного кода, поэтому ассемблер (который должен смотреть только на каждую инструкцию отдельно) не будет предупреждать вас.Хотя писать AH вообще довольно сомнительно;обычно плохая идея.Возможно, лучшим примером была бы стойка частичного флага с dec/jnz в цикле adc на процессорах до того, как семейство SnB сделало это дешевым. Проблемы с ADC / SBB и INC / DEC в тесных контурах на некоторых процессорах

Если вы ищете инструмент, чтобы предупредить вас о дорогих инструкциях, GAS будет не Это. Инструменты статического анализа, такие как IACA или LLVM-MCA, могут помочь вам показать дорогие инструкции в блоке кода. ( Что такое IACA и как его использовать? и (Как) я могу предсказать время выполнения фрагмента кода с помощью анализатора машинного кода LLVM? ) Они предназначены для анализа циклов, но дают им блок кода, независимо от того, является ли это тело цикла или нет, чтобы они отображалисьВы знаете, сколько мопов каждая инструкция стоит во внешнем интерфейсе, и, возможно, что-то о задержке.

Но на самом деле вам нужно немного больше узнать о конвейере, который вы оптимизируете, чтобы понять, какова стоимость каждой инструкциизависит от окружающего кода (является ли он частью длинной цепочки зависимостей и каково общее узкое место).Связанный:


GCC / clang -O0 Самым большим эффектом является вообще без оптимизации между операторами , что приводит к потере всего объема памяти и перезагрузке, поэтому каждый оператор C полностью реализуется отдельным блоком asm-инструкций.(Для последовательной отладки, в том числе для изменения переменных C при остановке на любой точке останова).

Но даже в пределах блока asm для одного оператора clang -O0, очевидно, пропускает этап оптимизации, который решает, использовать ли память CISCинструкциями назначения будет выигрыш (учитывая текущую настройку) .Так что самый простой код-генератор clang имеет тенденцию использовать ЦП в качестве машины хранения нагрузки с отдельными инструкциями загрузки для получения данных в регистрах.

GCC -O0 происходит для компиляции вашего основного кода, как вы могли ожидать.(При включенной оптимизации она, конечно, компилируется в xor %eax,%eax / ret, потому что a не используется.)

main:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $5, -4(%rbp)
    addl    $6, -4(%rbp)
    movl    $0, %eax
    popq    %rbp
    ret

Как просмотреть clang / LLVM с использованием memory-destination add

Я поместил эти функции в проводник компилятора Godbolt с помощью clang8.2 -O3 . Каждая функция скомпилирована в одну ассемблерную инструкцию со значением по умолчанию -mtune=generic для x86-64. (Поскольку современные процессоры x86 декодируют назначение памяти, эффективно добавляют максимально возможное количество внутренних операций в виде отдельной загрузки / добавления /сохраняйте инструкции, а иногда и меньше с микросинтезом load + add part.)

void add_reg_to_mem(int *p, int b) {
    *p += b;
}

 # I used AT&T syntax because that's what you were using.  Intel-syntax is nicer IMO
    addl    %esi, (%rdi)
    ret

void add_imm_to_mem(int *p) {
    *p += 3;
}

  # gcc and clang -O3 both emit the same asm here, where there's only one good choice
    addl    $3, (%rdi)
    ret

Вывод gcc -O0 является просто мозговой потерей, например, перезагрузка p дважды, потому что он забивает указатель при вычислении+3.Я мог бы также использовать глобальные переменные вместо указателей, чтобы дать компилятору то, что он не мог оптимизировать.-O0 для этого, вероятно, было бы намного менее ужасно.

    # gcc8.2 -O0 output
    ... after making a stack frame and spilling `p` from RDI to -8(%rbp)
    movq    -8(%rbp), %rax        # load p
    movl    (%rax), %eax          # load *p, clobbering p
    leal    3(%rax), %edx         # edx = *p + 3
    movq    -8(%rbp), %rax        # reload p
    movl    %edx, (%rax)          # store *p + 3

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

Вывод clang -O0 оказался для этого менее ужасным:

 # clang -O0
   ... after making a stack frame and spilling `p` from RDI to -8(%rbp)
    movq    -8(%rbp), %rdi    # reload p
    movl    (%rdi), %eax      # eax = *p
    addl    $3, %eax          # eax += 3
    movl    %eax, (%rdi)      # *p = eax

См. также Как удалить «шум» из GCC /вывод сборки clang? для получения дополнительной информации о написании функций, которые компилируются в интересный asm без оптимизации.


Если я скомпилирую с -m32 -mtune=pentium, gcc -O3 будет избегать добавления в память-dst:

Микроархитектура P5 Pentium (с 1993 г.) не декодирует в RISC-подобные внутренние мопы .Выполнение сложных инструкций занимает больше времени и приводит в порядок конвейер двойного выпуска-суперскаляр.Таким образом, GCC избегает их, используя более подмножество инструкций x86 RISCy, чтобы P5 мог лучше транслировать.

# gcc8.2 -O3 -m32 -mtune=pentium
add_imm_to_mem(int*):
    movl    4(%esp), %eax    # load p from the stack, because of the 32-bit calling convention

    movl    (%eax), %edx     # *p += 3 implemented as 3 separate instructions
    addl    $3, %edx
    movl    %edx, (%eax)
    ret

Вы можете попробовать это самостоятельно по ссылке Godbolt выше;вот откуда это.Просто измените компилятор на gcc в раскрывающемся списке и измените параметры.

Не уверен, что это на самом деле большая победа, потому что они спиной к спине.Чтобы это была настоящая победа, gcc должен был бы чередовать некоторые независимые инструкции.Согласно таблицам инструкций Agner Fog , add $imm, (mem) для P5 по порядку занимает 3 такта, но может применяться в U или V трубе.Прошло много времени с тех пор, как я прочитал раздел P5 Pentium его руководства по микроархитектуре, но конвейер порядка определенно должен запускать каждой инструкции в программном порядке.(Медленные инструкции, в том числе хранилища, могут быть завершены позже, однако, после запуска других инструкций. Но здесь добавление и сохранение зависят от предыдущей инструкции, поэтому им определенно придется подождать).

В случае, если выВ недоумении Intel по-прежнему использует бренды Pentium и Celeron для таких современных процессоров низкого уровня, как Skylake.Это не о чем мы говорим.Мы говорим об оригинальной микроархитектуре Pentium , с которой современные процессоры Pentium даже не связаны.

GCC отказывается -mtune=pentium без -m32, потому что нет 64-битовые процессоры Pentium.Xeon Phi первого поколения использует Knight's Corner uarch, основанный на обычном P5 Pentium с векторными расширениями, похожими на AVX512.Но gcc, похоже, не поддерживает -mtune=knc.Clang делает, но решает использовать здесь назначение памяти, добавленное здесь для этого и для -m32 -mtune=pentium.

Проект LLVM не начинался до тех пор, пока P5 не устарел (кроме KNC), в то время как gcc активно развивался ив то время как P5 широко использовался для настольных компьютеров x86.Поэтому неудивительно, что gcc все еще знает некоторые настройки P5, в то время как LLVM на самом деле не трактует это иначе, чем современный x86, который декодирует инструкции назначения памяти для нескольких операций и может выполнять их не по порядку.

...