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

Я разбираю этот код на 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 ответ

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

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


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

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

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

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

Если вам это не нужно, вы можете скомпилировать с -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 выше Часто очень удобно для поиска интересной части внутреннего цикла в оптимизированном выводе компилятора.)

# clang7.0 -O3  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.)

Другие вопросы по тегам