Почему 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)
...
Видимо, он делает следующее:
- загрузка двух поплавков в регистры xmm0 и xmm1
- положить их в стек
- загрузить одно значение (а не то, которое было раньше у xmm0) из стека в xmm0
- выполнить сложение.
- сохранить результат обратно в стек.
Я считаю это неэффективным, потому что:
- Все можно сделать в реестре. Я не буду использовать a и b позже, поэтому он может просто пропустить любую операцию, связанную со стеком.
- даже если бы он хотел использовать стек, он мог бы сохранить перезагрузку 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.)