Я впервые заметил в 2009 году, что GCC (по крайней мере, в моих проектах и на моих машинах) имеет тенденцию генерировать заметно более быстрый код, если я оптимизирую для size ( -Os
) вместо скорости ( -O2
или -O3
), и с тех пор я удивляюсь, почему.
Мне удалось создать (довольно глупый) код, который демонстрирует это удивительное поведение и достаточно мал, чтобы быть размещенным здесь.
const int LOOP_BOUND = 200000000;
__attribute__((noinline))
static int add(const int& x, const int& y) {
return x + y;
}
__attribute__((noinline))
static int work(int xval, int yval) {
int sum(0);
for (int i=0; i<LOOP_BOUND; ++i) {
int x(xval+sum);
int y(yval+sum);
int z = add(x, y);
sum += z;
}
return sum;
}
int main(int , char* argv[]) {
int result = work(*argv[1], *argv[2]);
return result;
}
Если я скомпилирую его -Os
, выполнение этой программы займет 0,38 с и 0,44 с, если она скомпилирована с помощью -O2
или -O3
. Эти времена получены последовательно и практически без помех (gcc 4.7.2, x86_64 GNU / Linux, Intel Core i5-3320M).
(Обновление: я переместил весь ассемблерный код на GitHub : они сделали публикацию раздутой и, по-видимому, добавили очень мало значения к вопросам, поскольку fno-align-*
флаги имеют тот же эффект.)
Вот сгенерированная сборка с -Os
и -O2
.
К сожалению, мое понимание сборки очень ограничено, так что я понятия не имею ли то , что я делал дальше , было правильно: я схватил сборку для -O2
и объединить все свои различия в сборку за -Os
исключением тех .p2align
линий, результат здесь . Этот код по-прежнему работает в 0.38 с, и единственное отличие состоит в .p2align
материале.
Если я правильно угадал, это отступы для выравнивания стека. Согласно Почему GCC pad работает с NOP? это сделано в надежде, что код будет работать быстрее, но, очевидно, эта оптимизация не принесла результатов в моем случае.
В этом случае виновником является прокладка? Почему и как?
Шум, который он издает, делает невозможным микро-оптимизацию синхронизации.
Как я могу убедиться, что такие случайные удачные / неудачные выравнивания не мешают, когда я выполняю микрооптимизации (не связанные с выравниванием по стеку) в исходном коде C или C ++?
ОБНОВИТЬ:
После ответа Паскаля Куока я немного повозился с выравниванием. Переходя -O2 -fno-align-functions -fno-align-loops
к gcc, все .p2align
уходит из сборки, и сгенерированный исполняемый файл запускается за 0.38 с. Согласно документации gcc :
-Os включает все оптимизации -O2 [но] -Os отключает следующие флаги оптимизации:
-falign-functions -falign-jumps -falign-loops -falign-labels -freorder-blocks -freorder-blocks-and-partition -fprefetch-loop-arrays
Таким образом, это в значительной степени похоже на (неправильную) проблему выравнивания.
Я все еще скептически отношусь к тому, -march=native
что было предложено в ответе Марата Духана . Я не уверен, что это не только мешает этой (неправильной) проблеме выравнивания; это абсолютно не влияет на мою машину. (Тем не менее, я проголосовал за его ответ.)
ОБНОВЛЕНИЕ 2:
Мы можем взять -Os
из картины. Следующие времена получены путем компиляции с
-O2 -fno-omit-frame-pointer
0.37s-O2 -fno-align-functions -fno-align-loops
0.37s-S -O2
затем вручную перемещая сборкуadd()
черезwork()
0,37 с-O2
0.44s
Похоже, для меня add()
большое значение имеет расстояние от сайта вызова. Я пытался perf
, но вывод perf stat
и perf report
имеет очень мало смысла для меня. Тем не менее, я мог получить только один последовательный результат из этого:
-O2
:
602,312,864 stalled-cycles-frontend # 0.00% frontend cycles idle
3,318 cache-misses
0.432703993 seconds time elapsed
[...]
81.23% a.out a.out [.] work(int, int)
18.50% a.out a.out [.] add(int const&, int const&) [clone .isra.0]
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ return x + y;
100.00 ¦ lea (%rdi,%rsi,1),%eax
¦ }
¦ ? retq
[...]
¦ int z = add(x, y);
1.93 ¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
79.79 ¦ add %eax,%ebx
Для fno-align-*
:
604,072,552 stalled-cycles-frontend # 0.00% frontend cycles idle
9,508 cache-misses
0.375681928 seconds time elapsed
[...]
82.58% a.out a.out [.] work(int, int)
16.83% a.out a.out [.] add(int const&, int const&) [clone .isra.0]
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ return x + y;
51.59 ¦ lea (%rdi,%rsi,1),%eax
¦ }
[...]
¦ __attribute__((noinline))
¦ static int work(int xval, int yval) {
¦ int sum(0);
¦ for (int i=0; i<LOOP_BOUND; ++i) {
¦ int x(xval+sum);
8.20 ¦ lea 0x0(%r13,%rbx,1),%edi
¦ int y(yval+sum);
¦ int z = add(x, y);
35.34 ¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
39.48 ¦ add %eax,%ebx
¦ }
Для -fno-omit-frame-pointer
:
404,625,639 stalled-cycles-frontend # 0.00% frontend cycles idle
10,514 cache-misses
0.375445137 seconds time elapsed
[...]
75.35% a.out a.out [.] add(int const&, int const&) [clone .isra.0] ¦
24.46% a.out a.out [.] work(int, int)
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
18.67 ¦ push %rbp
¦ return x + y;
18.49 ¦ lea (%rdi,%rsi,1),%eax
¦ const int LOOP_BOUND = 200000000;
¦
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ mov %rsp,%rbp
¦ return x + y;
¦ }
12.71 ¦ pop %rbp
¦ ? retq
[...]
¦ int z = add(x, y);
¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
29.83 ¦ add %eax,%ebx
Похоже, мы остановились на вызове add()
в медленном случае.
Я изучил все, что perf -e
может выплюнуть на моей машине; не только статистика, которая приведена выше.
Для того же исполняемого файла stalled-cycles-frontend
показана линейная корреляция со временем выполнения; Я не заметил ничего другого, что так четко соотносилось бы. (Сравнение stalled-cycles-frontend
для разных исполняемых файлов не имеет смысла для меня.)
Я включил пропуски кэша, так как он появился в качестве первого комментария. Я изучил все ошибки кэша, которые можно измерить на моей машине perf
, а не только те, которые приведены выше. Промахи в кеше очень шумные и практически не коррелируют со временем выполнения.
Ответы:
По умолчанию компиляторы оптимизируют для «среднего» процессора. Поскольку разные процессоры предпочитают разные последовательности команд, включенная оптимизация компилятора
-O2
может принести пользу среднему процессору, но снизить производительность вашего конкретного процессора (и то же самое относится к-Os
). Если вы попробуете один и тот же пример на разных процессорах, вы обнаружите, что на некоторых из них выгода, в-O2
то время как другие более благоприятны для-Os
оптимизации.Вот результаты для
time ./test 0 0
нескольких процессоров (время пользователя сообщается):В некоторых случаях вы можете смягчить эффект невыгодных оптимизаций, попросив
gcc
выполнить оптимизацию для вашего конкретного процессора (используя опции-mtune=native
или-march=native
):Обновление: в Core i3 на базе Ivy Bridge три версии
gcc
(4.6.4
,4.7.3
и4.8.1
) производят двоичные файлы со значительно отличающейся производительностью, но код сборки имеет лишь незначительные различия. Пока у меня нет объяснения этому факту.Сборка из
gcc-4.6.4 -Os
(выполняется за 0,709 с):Сборка из
gcc-4.7.3 -Os
(выполняется за 0,822 секунды):Сборка из
gcc-4.8.1 -Os
(выполняется за 0,994 секунды):источник
-O2 -fno-align-functions -fno-align-loops
падает время до0.340s
, поэтому это можно объяснить выравниванием. Однако оптимальное выравнивание зависит от процессора: некоторые процессоры предпочитают выровненные циклы и функции.Мой коллега помог мне найти правдоподобный ответ на мой вопрос. Он заметил важность 256-байтовой границы. Он не зарегистрирован здесь и призвал меня опубликовать ответ сам (и принять всю известность).
Короткий ответ:
Все сводится к выравниванию. Выравнивания могут оказать значительное влияние на производительность, поэтому
-falign-*
флаги у нас на первом месте.Я отправил (фальшивый?) Отчет об ошибке разработчикам gcc . Оказывается, что поведение по умолчанию - «мы выравниваем циклы по 8 байт по умолчанию, но стараемся выровнять его по 16 байтам, если нам не нужно заполнять более 10 байтов».Видимо, этот дефолт не лучший выбор в данном конкретном случае и на моей машине. Clang 3.4 (trunk)
-O3
делает соответствующее выравнивание, и сгенерированный код не показывает это странное поведение.Конечно, если неправильное выравнивание сделано, это ухудшает ситуацию. Ненужное / плохое выравнивание просто съедает байты без причины и потенциально увеличивает количество кешей и т. Д.
Просто указав gcc сделать правильное выравнивание:
g++ -O2 -falign-functions=16 -falign-loops=16
Длинный ответ:
Код будет работать медленнее, если:
Н.
XX
байтовые граничные разрезыadd()
в середине (XX
будучи машиннозависимый).если вызов
add()
должен перепрыгнуть черезXX
границу байта и цель не выровнена.если
add()
не выровнен.если цикл не выровнен.
Первые 2 прекрасно видны на кодах и результатах, которые любезно разместил Марат Духан . В этом случае
gcc-4.8.1 -Os
(выполняется за 0,994 секунды):граница 256 байт режет
add()
прямо посередине и ниadd()
цикл, ни цикл не выровнены. Сюрприз, сюрприз, это самый медленный случай!В случае
gcc-4.7.3 -Os
(выполняется за 0,822 секунды), граница в 256 байт только врезается в холодную секцию (но не в цикл и неadd()
обрезается):Ничто не выравнивается, и призыв к
add()
должен перепрыгнуть через 256-байтовую границу. Этот код является вторым самым медленным.В случае
gcc-4.6.4 -Os
(выполняется за 0,709 с), хотя ничего не выровнено, вызовуadd()
не нужно перепрыгивать через 256-байтовую границу, а цель находится ровно в 32 байтах:Это самый быстрый из всех трех. Почему 256-байтовая граница является особенной на его машине, я оставлю это на его усмотрение, чтобы выяснить это. У меня нет такого процессора.
Теперь на моей машине я не получаю этот 256-байтовый граничный эффект. На моей машине включается только функция и выравнивание петель. Если я прохожу,
g++ -O2 -falign-functions=16 -falign-loops=16
то все возвращается на круги своя: я всегда получаю самый быстрый случай, и время больше не зависит от-fno-omit-frame-pointer
флага. Я могу передатьg++ -O2 -falign-functions=32 -falign-loops=32
или любое число, кратное 16, код также не чувствителен к этому.Вероятное объяснение состоит в том, что у меня были горячие точки, которые были чувствительны к выравниванию, как в этом примере. Перемешав с флагами (передавая
-Os
вместо-O2
), эти горячие точки были случайно выровнены, и код стал быстрее. Это не имело ничего общего с оптимизацией по размеру: это было случайно, что горячие точки были выровнены лучше. С этого момента я буду проверять влияние выравнивания на мои проекты.Да, и еще одна вещь. Как могут возникать такие горячие точки, как показано в примере? Как может встраиваться такая крошечная функция, как
add()
сбой?Учти это:
и в отдельном файле:
и скомпилирован как:
g++ -O2 add.cpp main.cpp
.GCC не будет встроен
add()
!Вот и все, это так просто непреднамеренно создать горячие точки, как в OP. Конечно, это отчасти моя вина: gcc - отличный компилятор. Если скомпилировать вышеизложенное как:,
g++ -O2 -flto add.cpp main.cpp
то есть, если я выполняю оптимизацию времени соединения, код выполняется за 0,19 с!(В OP искусственно отключено встраивание, следовательно, код в OP был в 2 раза медленнее).
источник
inline
+ определение функции в заголовке. Не уверен, насколько зрелым является gcc. Мой опыт работы с ним, по крайней мере, в Mingw, является хитом или промахом.-flto
. это довольно революционно, если вы никогда не использовали его раньше, если судить по опыту :)Я добавляю этот пост-акцепт, чтобы указать, что влияние выравнивания на общую производительность программ, в том числе больших, было изучено. Например, эта статья (и я полагаю, что эта версия также появилась в CACM) показывает, как одного лишь изменения порядка ссылок и размера среды ОС было достаточно для значительного смещения производительности. Они связывают это с выравниванием «горячих петель».
Эта статья под названием "Создание неправильных данных без каких-либо действий, которые явно не соответствуют действительности!" говорит, что непреднамеренный экспериментальный уклон из-за почти неконтролируемых различий в средах выполнения программ, вероятно, делает бессмысленными многие результаты тестов.
Я думаю, что вы сталкиваетесь с другим углом зрения на одно и то же наблюдение.
Для кода, критичного к производительности, это довольно хороший аргумент для систем, которые оценивают среду во время установки или выполнения и выбирают локальную лучшую версию из по-разному оптимизированных версий ключевых подпрограмм.
источник
Я думаю, что вы можете получить тот же результат, что и вы:
... используя
-O2 -falign-functions=1 -falign-jumps=1 -falign-loops=1 -falign-labels=1
. Я собирал все с этими опциями, которые были быстрее, чем обычные,-O2
каждый раз, когда я пытался измерить, в течение 15 лет.Кроме того, для совершенно другого контекста (включая другой компилятор) я заметил, что ситуация похожа : опция, которая должна «оптимизировать размер кода, а не скорость», оптимизирует размер кода и скорость.
Нет, это не имеет никакого отношения к стеку, NOP, которые генерируются по умолчанию, и что опции -falign - * = 1 предотвращают это для выравнивания кода.
Весьма вероятно, что обивка является виновником. Причина того, что заполнение считается необходимым, и в некоторых случаях полезно, заключается в том, что код обычно выбирается в строках по 16 байтов (подробности см. В ресурсах оптимизации Agner Fog , которые зависят от модели процессора). Выравнивание функции, цикла или метки на 16-байтовой границе означает, что шансы статистически увеличиваться, что для размещения функции или цикла потребуется меньше строк. Очевидно, что это приводит к обратным последствиям, поскольку эти NOP уменьшают плотность кода и, следовательно, эффективность кэширования. В случае циклов и метки, NOP может даже потребоваться выполнить один раз (когда выполнение поступает в цикл / метку нормально, в отличие от перехода).
источник
-O2 -fno-omit-frame-pointer
это так же хорошо, как-Os
. Пожалуйста, проверьте обновленный вопрос.Если ваша программа ограничена кешем CODE L1, то оптимизация по размеру внезапно начинает приносить плоды.
Когда я проверял последний раз, компилятор не был достаточно умен, чтобы понять это во всех случаях.
В вашем случае -O3, вероятно, генерирует код, достаточный для двух строк кэша, но -Os помещается в одну строку кэша.
источник
-falign-*=16
флаги, все возвращается в норму, все ведет себя последовательно. Насколько мне известно, этот вопрос решен.Я ни в коем случае не эксперт в этой области, но я помню, что современные процессоры довольно чувствительны, когда дело доходит до предсказания ветвлений . Алгоритмы, используемые для предсказания ветвей, (или, по крайней мере, были в те времена, когда я писал код на ассемблере) основывались на нескольких свойствах кода, включая расстояние до цели и направление.
Сценарий, который приходит на ум, - это маленькие петли. Когда ветвление шло назад, а расстояние было не слишком большим, предсказание ветвления оптимизировалось для этого случая, так как все маленькие циклы выполняются таким образом. Те же правила могут вступить в действие, когда вы меняете месторасположение
add
иwork
в сгенерированном коде или когда положение обоих немного меняется.Тем не менее, я понятия не имею, как это проверить, и я просто хотел, чтобы вы знали, что это может быть то, что вы хотите посмотреть.
источник
add()
иwork()
если-O2
это пропущено. Во всех остальных случаях код значительно замедляется при замене. В выходные я также анализировал статистику прогнозирования / ошибочного прогнозирования ветвленийperf
и не заметил ничего, что могло бы объяснить это странное поведение. Единственный непротиворечивый результат заключается в том, что в медленном регистреperf
сообщается значение 100.0 inadd()
и большое значение в строке сразу после вызоваadd()
цикла. Похоже, мы по какой-то причине остановилисьadd()
в медленном случае, но не в быстрых.perf
поддерживает только ограниченное количество вещей, возможно, вещи Intel немного удобнее на их собственном процессоре.