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

Я написал этот простой ассемблерный код, запустил его и посмотрел на область памяти, используя 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

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

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

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

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


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

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

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

Но в нынешнем виде add $6, -4(%rbp) декодирование в 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, таких как 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- jnz test ecx,ecx/jnz). Оптимизация под размер кода полезна в реальной жизни для загрузочных секторов или для забавных вещей, таких как 512-байтовые или 4-килобайтные "демонстрации", которые рисуют классную графику и воспроизводят звук только в крошечных количествах исполняемых файлов. Или для кода, который выполняется только один раз при запуске, чем меньше размер файла, тем лучше. Или же редко выполняется в течение срока жизни программы, меньший объем I-кэша лучше, чем удаление большого количества кэша (и страдание внешнего интерфейса, ожидающего выборки кода). Это может перевесить максимальную эффективность, когда байты инструкций действительно поступают в ЦП и декодируются. Особенно, если разница небольшая по сравнению с сохранением размера кода.

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


Для того, чтобы избежать замедлений, нужно одновременно просматривать более 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 заключается в том, что между операторами не происходит никакой оптимизации, все -O0 в память и перезагружается, поэтому каждый оператор C полностью реализуется отдельным блоком asm-инструкций. (Для последовательной отладки, включая изменение переменных C при остановке на любой точке останова).

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

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, используя add -destination

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

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

 # I used AT&T syntax because that 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 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 использует Uarch Knight Corner, основанный на заказанном P5 Pentium с векторными расширениями, похожими на AVX512. Но gcc, похоже, не поддерживает -mtune=knc. Clang делает, но решает использовать добавление памяти для этого здесь и для -m32 -mtune=pentium.

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

Ответ 2

Посмотрите коды операций, которые мнемоника add может сопоставить с:

https://www.felixcloutier.com/x86/add

Есть коды операций для:

  • Добавление немедленного значения 1 в регистр или место назначения памяти (что вы делаете в своей рукописной сборке)
  • Добавление значения в памяти в регистр назначения
  • Добавление значения в регистр в регистр или место назначения памяти

Обратите внимание на комментарий:

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

Акцент мой.

В полностью неоптимизированном C-коде все переменные хранятся в памяти и не заменяются немедленными, даже если в сгенерированном коде они используются только для загрузки значения в регистр. Это также (в основном) верно для переменных, объявленных с квалификатором volatile на большинстве компиляторов, независимо от настроек оптимизации.

1: то есть число, буквально жестко запрограммированное в инструкции, например, " add $10, %eax "