Почему clang создает неэффективный asm с -O0 (для этой простой суммы с плавающей запятой)? - PullRequest
0 голосов
/ 19 ноября 2018

Я разбираю этот код на llvm clang Apple LLVM версии 8.0.0 (clang-800.0.42.1):

int main() {
    float a=0.151234;
    float b=0.2;
    float c=a+b;
    printf("%f", c);
}

Я скомпилировал без спецификаций -O, но я также попытался с -O0 (дает то же самое) и -O2 (фактически вычисляет значение и сохраняет его предварительно вычисленным)

В результате разборка выглядит следующим образом (я удалил части, которые не имеют отношения к делу)

->  0x100000f30 <+0>:  pushq  %rbp
    0x100000f31 <+1>:  movq   %rsp, %rbp
    0x100000f34 <+4>:  subq   $0x10, %rsp
    0x100000f38 <+8>:  leaq   0x6d(%rip), %rdi       
    0x100000f3f <+15>: movss  0x5d(%rip), %xmm0           
    0x100000f47 <+23>: movss  0x59(%rip), %xmm1        
    0x100000f4f <+31>: movss  %xmm1, -0x4(%rbp)  
    0x100000f54 <+36>: movss  %xmm0, -0x8(%rbp)
    0x100000f59 <+41>: movss  -0x4(%rbp), %xmm0         
    0x100000f5e <+46>: addss  -0x8(%rbp), %xmm0
    0x100000f63 <+51>: movss  %xmm0, -0xc(%rbp)
    ...

Видимо, он делает следующее:

  1. загрузка двух чисел на регистры xmm0 и xmm1
  2. положить их в стек
  3. загрузить одно значение (а не то, которое было у xmm0 ранее) из стека в xmm0
  4. выполнить сложение.
  5. сохранить результат обратно в стек.

Я считаю это неэффективным, потому что:

  1. Все можно сделать в реестре. Позже я не использую a и b, поэтому он может просто пропустить любую операцию, связанную со стеком.
  2. даже если бы он хотел использовать стек, он мог бы сохранить перезагрузку xmm0 из стека, если бы выполнял операцию в другом порядке.

Учитывая, что компилятор всегда прав, почему он выбрал эту стратегию?

1 Ответ

0 голосов
/ 19 ноября 2018

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

(-O0 не означает буквально никакой оптимизации;например, gcc будет по-прежнему исключать код внутри блоков if(1 == 2){ }. Особенно gcc больше, чем большинство других компиляторов, по-прежнему использует мультипликативные инверсии для деления при -O0, потому что он все еще преобразует ваш источник C через множество внутренних представлений логики перед тем, как в конце концов выдатьasm.)

Плюс, «компилятор всегда прав» - это преувеличение даже при -O3.Компиляторы очень хороши в больших масштабах, но незначительные пропущенные оптимизации все еще распространены в одиночных циклах.Часто с очень низким воздействием, но потраченные впустую инструкции (или мопы) в цикле могут занимать пространство в окне переупорядочения выполнения не по порядку и быть менее дружественными к гиперпоточности при совместном использовании ядра с другим потоком.См. C ++-код для проверки гипотезы Коллатца быстрее, чем рукописной сборки - почему? для получения дополнительной информации об избиении компилятора в простом конкретном случае.


Что более важно,-O0 также подразумевает обработку всех переменных, аналогичных volatile, для согласованной отладки .то есть вы можете установить точку останова или один шаг и изменить значение переменной C, а затем продолжить выполнение и заставить программу работать так, как вы ожидаете, если ваш источник C работает на абстрактной машине C,Таким образом, компилятор не может выполнять какое-либо постоянное распространение или упрощение диапазона значений.(Например, целое число, которое, как известно, неотрицательно, может упростить вещи, используя его, или сделать некоторые, если условия всегда истинны или всегда ложны.)

(Это не совсем так плохо, как volatile: множественные ссылки на одну и ту же переменную в одном выражении не всегда приводят к множественным нагрузкам; при -O0 компиляторы все равно будут несколько оптимизироваться в рамках одного выражения.)

Компиляторы должны специальнооптимизировать для -O0, сохраняя / перезагружая все переменные по их адресу памяти между операторами .(В C и C ++ каждая переменная имеет адрес, если только она не была объявлена ​​с помощью (теперь устаревшего) ключевого слова register и никогда не получал свой адрес. Оптимизация адреса возможна в соответствии с правилом «как будто» для других переменных,но это не сделано в -O0)

К сожалению, форматы отладочной информации не могут отслеживать местоположение переменной через регистры, поэтому полностью согласованная отладка невозможна без этого медленного и тупого кода-gen.

Если вам это не нужно, вы можете скомпилировать с -Og для легкой оптимизации и без антиоптимизаций, необходимых для последовательной отладки.Руководство GCC рекомендует его для обычного цикла редактирования / компиляции / запуска, но вы будете «оптимизированы» для многих локальных переменных с автоматическим хранением при отладке.Глобальные и аргументы функций обычно обычно имеют свои фактические значения, по крайней мере на границах функций.


Еще хуже, -O0 делает код, который все еще работает, даже если вы используете команду GDB jump дляпродолжить выполнение в другой строке источника .Таким образом, каждый оператор C должен быть скомпилирован в полностью независимый блок инструкций.( Можно ли «прыгать» / «пропускать» в отладчике GDB? )

for() петли не могут быть преобразованы в идиоматические (для asm) do{}while()циклы и другие ограничения.

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

Узкие места в коде -O0 часто будут отличаться от -O3 - часто в счетчике циклов, который хранится в памяти, создавая цепочку зависимостей, переносимых циклами ~ 6 циклов. Это может создать интересные эффекты в asm, сгенерированном компилятором, например Добавление избыточного назначения ускоряет код при компиляции без оптимизации (что интересно с точки зрения asm, но не для C.)

«Мой тест оптимизирован иначе» - неоправданное оправдание для оценки производительности кода -O0. См. Справка по оптимизации цикла C для окончательного назначения для примера и более подробной информации о кроличьей норе, для которой настроена -O0.


Получение интересного вывода компилятора

Если вы хотите увидеть, как компилятор добавляет 2 переменные, напишите функцию, которая принимает аргументы и возвращает значение . Помните, что вы хотите только смотреть на asm, но не запускать его, поэтому вам не нужны main или любые числовые литеральные значения для всего, что должно быть переменной времени выполнения.

См. Также Как удалить «шум» из выходных данных сборки GCC / clang? Подробнее об этом.

float foo(float a, float b) {
    float c=a+b;
    return c;
}

компилируется с clang -O3 ( в проводнике компилятора Godbolt ) до ожидаемого

    addss   xmm0, xmm1
    ret

Но с -O0 это выливает аргументы в стек памяти. (Godbolt использует отладочную информацию, испускаемую компилятором, для цветового кодирования asm-инструкций в соответствии с тем, из какого оператора C они получены. Я добавил разрывы строк, чтобы показать блоки для каждого оператора, но вы можете увидеть это с помощью цветовой подсветки на ссылке Godbolt выше . Часто очень удобно для нахождения интересной части внутреннего цикла в оптимизированном выводе компилятора.)

gcc -fverbose-asm будет помещать комментарии в каждой строке, показывая имена операндов как C vars. В оптимизированном коде это часто внутреннее имя tmp, но в неоптимизированном коде это обычно фактическая переменная из источника Си. Я вручную прокомментировал вывод clang, потому что он этого не делает.

# clang7.0 -O0  also on Godbolt
foo:
    push    rbp
    mov     rbp, rsp                  # make a traditional stack frame
    movss   DWORD PTR [rbp-20], xmm0  # spill the register args
    movss   DWORD PTR [rbp-24], xmm1  # into the red zone (below RSP)

    movss   xmm0, DWORD PTR [rbp-20]  # a
    addss   xmm0, DWORD PTR [rbp-24]  # +b
    movss   DWORD PTR [rbp-4], xmm0   # store c

    movss   xmm0, DWORD PTR [rbp-4]   # return 0
    pop     rbp                       # epilogue
    ret

Интересный факт: при использовании register float c = a+b; возвращаемое значение может оставаться в XMM0 между операторами, а не разливаться / перезагружаться. Переменная не имеет адреса. (Я включил эту версию функции в ссылку Godbolt.)

Ключевое слово register не влияет на оптимизированный код (за исключением того, что ошибочно берется адрес переменной, например, как const в локальной системе не дает вам случайно что-то изменить). Я не рекомендую использовать его, но интересно видеть, что он действительно влияет на неоптимизированный код.


Связанный:

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...