Почему GCC генерирует код на 15-20% быстрее, если я оптимизирую размер вместо скорости?
В 2009 году я впервые заметил, что GCC (по крайней мере, в моих проектах и на моих машинах) имеет тенденцию генерировать заметно более быстрый код, если я оптимизирую по размеру (-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
сборку для -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 <br/>
-falign-labels -freorder-blocks -freorder-blocks-and-partition <br/>
-fprefetch-loop-arrays <br/>
Таким образом, это в значительной степени похоже на (неправильную) проблему выравнивания.
Я все еще скептически отношусь к -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.37s
-
-O2
0,44 с
Похоже, для меня большое значение имеет расстояние 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
, а не только те, которые приведены выше. Промахи в кеше очень шумные и практически не коррелируют со временем выполнения.
Ответы
Ответ 1
Мой коллега помог мне найти правдоподобный ответ на мой вопрос. Он заметил важность границы 256 байт. Он не зарегистрирован здесь и не рекомендовал мне опубликовать ответ сам (и взять всю известность).
Короткий ответ:
Является ли это прописью, которая является виновником в этом случае? Почему и как?
Все это сводится к выравниванию. Выравнивание может оказать существенное влияние на производительность, поэтому в первую очередь мы имеем флаги -falign-*
.
Я отправил (bogus?) отчет об ошибках разработчикам gcc. Оказывается, что поведение по умолчанию - "мы выравниваем петли до 8 байтов по умолчанию, но пытаемся выровнять его до 16 байт, если нам не нужно заполнять более 10 байтов". По-видимому, это по умолчанию не самый лучший выбор в данном конкретном случае и на моей машине. Clang 3.4 (trunk) с -O3
выполняет соответствующее выравнивание, а сгенерированный код не показывает это странное поведение.
Конечно, , если выполняется несоответствующее выравнивание, это ухудшает ситуацию. Неисправное/плохое выравнивание просто поглощает байты без причины и потенциально увеличивает количество промахов в кеше и т.д.
Шум, который он делает в значительной степени, делает временную микро-оптимизацию невозможно.
Как я могу убедиться, что такие случайные удачные/неудачные выравнивания не мешают, когда я выполняю микрооптимизацию (не связанную с стеком выравнивание) в исходных кодах C или С++?
Просто сообщив gcc о правильном выравнивании:
g++ -O2 -falign-functions=16 -falign-loops=16
Длинный ответ:
Код будет работать медленнее, если:
-
an XX
граничные сокращения add()
в середине (XX
зависит от машины).
-
если вызов add()
должен перепрыгнуть через границу байта XX
, а цель не выровнена.
-
если add()
не выровнено.
-
если петля не выровнена.
Первые 2 красиво видны на кодах и показывают, что Марат Духан любезно опубликовал. В этом случае gcc-4.8.1 -Os
(выполняется в 0.994 сек):
00000000004004fd <_ZL3addRKiS0_.isra.0>:
4004fd: 8d 04 37 lea eax,[rdi+rsi*1]
400500: c3
256-байтные граничные сокращения add()
справа в середине, и ни add()
, ни петля не выровнены. Сюрприз, удивление, это самый медленный случай!
В случае gcc-4.7.3 -Os
(выполняется через 0,822 секунды) граница 256 байт только разрезается на холодную секцию (но ни цикл, ни add()
не разрезаются):
00000000004004fa <_ZL3addRKiS0_.isra.0>:
4004fa: 8d 04 37 lea eax,[rdi+rsi*1]
4004fd: c3 ret
[...]
40051a: e8 db ff ff ff call 4004fa <_ZL3addRKiS0_.isra.0>
Ничего не выровнено, а вызов add()
должен перепрыгнуть через границу 256 байт. Этот код является вторым самым медленным.
В случае gcc-4.6.4 -Os
(выполняется в 0.709 сек.), хотя ничто не выровнено, вызов add()
не должен перескакивать через границу 256 байтов, а цель - ровно на 32 байта:
4004f2: e8 db ff ff ff call 4004d2 <_ZL3addRKiS0_.isra.0>
4004f7: 01 c3 add ebx,eax
4004f9: ff cd dec ebp
4004fb: 75 ec jne 4004e9 <_ZL4workii+0x13>
Это самый быстрый из всех трех. Почему 256-байтовая граница является специальным на его машине, я оставлю его до него, чтобы понять это. У меня нет такого процессора.
Теперь, на моей машине, я не получаю этот 256-байтовый граничный эффект. Только функция и выравнивание цикла срабатывают на моей машине. Если я пройду g++ -O2 -falign-functions=16 -falign-loops=16
, тогда все вернется к норме: я всегда получаю самый быстрый случай, и время больше не чувствительно к значку -fno-omit-frame-pointer
. Я могу передать g++ -O2 -falign-functions=32 -falign-loops=32
или любые кратные 16, код также не чувствителен к этому.
Впервые я заметил в 2009 году, что gcc (по крайней мере, в моих проектах и на моем машины) имеют тенденцию генерировать значительно более быстрый код, если я оптимизируйте размер (-O) вместо скорости (-O2 или -O3), и я был интересно с тех пор почему.
Вероятное объяснение состоит в том, что у меня были горячие точки, которые были чувствительны к выравниванию, как и в этом примере. Путем возиться с флагами (прохождение -Os
вместо -O2
), эти горячие точки были случайно выровнены случайным образом, и код стал быстрее. Это не имело ничего общего с оптимизацией для размера: они были случайно, что горячие точки лучше выровнены. Теперь я проверю влияние выравнивания на свои проекты.
О, и еще одна вещь. Как могут возникать такие горячие точки, как показано в примере? Как может завершиться встраивание такой крошечной функции, как add()
?
Рассмотрим это:
// add.cpp
int add(const int& x, const int& y) {
return x + y;
}
и в отдельном файле:
// main.cpp
int add(const int& x, const int& y);
const int LOOP_BOUND = 200000000;
__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;
}
и скомпилирован как: g++ -O2 add.cpp main.cpp
.
gcc не будет встроен add()
!
Это все, что легко непреднамеренно создавать горячие точки, подобные тем, которые есть в OP. Конечно, это отчасти моя ошибка: gcc - отличный компилятор. Если скомпилировать выше: g++ -O2 -flto add.cpp main.cpp
, то есть , если я выполняю оптимизацию времени ссылки, код работает в 0.19s!
(Инвалидинг искусственно отключен в OP, следовательно, код в OP был 2x медленнее).
Ответ 2
По умолчанию компиляторы оптимизируются для "среднего" процессора. Поскольку разные процессоры предпочитают разные последовательности команд, оптимизация компилятора, поддерживаемая -O2
, может принести пользу среднему процессору, но снижает производительность на вашем конкретном процессоре (и то же самое относится к -Os
). Если вы попробуете тот же пример на разных процессорах, вы обнаружите, что некоторые из них получают выгоду от -O2
, тогда как другие более благоприятны для оптимизаций -Os
.
Ниже приведены результаты для time ./test 0 0
на нескольких процессорах (время пользователя указано):
Processor (System-on-Chip) Compiler Time (-O2) Time (-Os) Fastest
AMD Opteron 8350 gcc-4.8.1 0.704s 0.896s -O2
AMD FX-6300 gcc-4.8.1 0.392s 0.340s -Os
AMD E2-1800 gcc-4.7.2 0.740s 0.832s -O2
Intel Xeon E5405 gcc-4.8.1 0.603s 0.804s -O2
Intel Xeon E5-2603 gcc-4.4.7 1.121s 1.122s -
Intel Core i3-3217U gcc-4.6.4 0.709s 0.709s -
Intel Core i3-3217U gcc-4.7.3 0.708s 0.822s -O2
Intel Core i3-3217U gcc-4.8.1 0.708s 0.944s -O2
Intel Core i7-4770K gcc-4.8.1 0.296s 0.288s -Os
Intel Atom 330 gcc-4.8.1 2.003s 2.007s -O2
ARM 1176JZF-S (Broadcom BCM2835) gcc-4.6.3 3.470s 3.480s -O2
ARM Cortex-A8 (TI OMAP DM3730) gcc-4.6.3 2.727s 2.727s -
ARM Cortex-A9 (TI OMAP 4460) gcc-4.6.3 1.648s 1.648s -
ARM Cortex-A9 (Samsung Exynos 4412) gcc-4.6.3 1.250s 1.250s -
ARM Cortex-A15 (Samsung Exynos 5250) gcc-4.7.2 0.700s 0.700s -
Qualcomm Snapdragon APQ8060A gcc-4.8 1.53s 1.52s -Os
В некоторых случаях вы можете облегчить эффект невыгодной оптимизации, запросив gcc
оптимизировать для вашего конкретного процессора (используя опции -mtune=native
или -march=native
):
Processor Compiler Time (-O2 -mtune=native) Time (-Os -mtune=native)
AMD FX-6300 gcc-4.8.1 0.340s 0.340s
AMD E2-1800 gcc-4.7.2 0.740s 0.832s
Intel Xeon E5405 gcc-4.8.1 0.603s 0.803s
Intel Core i7-4770K gcc-4.8.1 0.296s 0.288s
Обновление: на Core I3 на основе Ivy Bridge три версии gcc
(4.6.4
, 4.7.3
и 4.8.1
) создают двоичные файлы со значительно другой производительностью, но код сборки имеет только тонкие вариации. До сих пор у меня нет объяснения этого факта.
Сборка из gcc-4.6.4 -Os
(выполняется в 0.709 сек):
00000000004004d2 <_ZL3addRKiS0_.isra.0>:
4004d2: 8d 04 37 lea eax,[rdi+rsi*1]
4004d5: c3 ret
00000000004004d6 <_ZL4workii>:
4004d6: 41 55 push r13
4004d8: 41 89 fd mov r13d,edi
4004db: 41 54 push r12
4004dd: 41 89 f4 mov r12d,esi
4004e0: 55 push rbp
4004e1: bd 00 c2 eb 0b mov ebp,0xbebc200
4004e6: 53 push rbx
4004e7: 31 db xor ebx,ebx
4004e9: 41 8d 34 1c lea esi,[r12+rbx*1]
4004ed: 41 8d 7c 1d 00 lea edi,[r13+rbx*1+0x0]
4004f2: e8 db ff ff ff call 4004d2 <_ZL3addRKiS0_.isra.0>
4004f7: 01 c3 add ebx,eax
4004f9: ff cd dec ebp
4004fb: 75 ec jne 4004e9 <_ZL4workii+0x13>
4004fd: 89 d8 mov eax,ebx
4004ff: 5b pop rbx
400500: 5d pop rbp
400501: 41 5c pop r12
400503: 41 5d pop r13
400505: c3 ret
Сборка из gcc-4.7.3 -Os
(выполняется через 0,822 секунды):
00000000004004fa <_ZL3addRKiS0_.isra.0>:
4004fa: 8d 04 37 lea eax,[rdi+rsi*1]
4004fd: c3 ret
00000000004004fe <_ZL4workii>:
4004fe: 41 55 push r13
400500: 41 89 f5 mov r13d,esi
400503: 41 54 push r12
400505: 41 89 fc mov r12d,edi
400508: 55 push rbp
400509: bd 00 c2 eb 0b mov ebp,0xbebc200
40050e: 53 push rbx
40050f: 31 db xor ebx,ebx
400511: 41 8d 74 1d 00 lea esi,[r13+rbx*1+0x0]
400516: 41 8d 3c 1c lea edi,[r12+rbx*1]
40051a: e8 db ff ff ff call 4004fa <_ZL3addRKiS0_.isra.0>
40051f: 01 c3 add ebx,eax
400521: ff cd dec ebp
400523: 75 ec jne 400511 <_ZL4workii+0x13>
400525: 89 d8 mov eax,ebx
400527: 5b pop rbx
400528: 5d pop rbp
400529: 41 5c pop r12
40052b: 41 5d pop r13
40052d: c3 ret
Сборка из gcc-4.8.1 -Os
(выполняется в 0.994 сек):
00000000004004fd <_ZL3addRKiS0_.isra.0>:
4004fd: 8d 04 37 lea eax,[rdi+rsi*1]
400500: c3 ret
0000000000400501 <_ZL4workii>:
400501: 41 55 push r13
400503: 41 89 f5 mov r13d,esi
400506: 41 54 push r12
400508: 41 89 fc mov r12d,edi
40050b: 55 push rbp
40050c: bd 00 c2 eb 0b mov ebp,0xbebc200
400511: 53 push rbx
400512: 31 db xor ebx,ebx
400514: 41 8d 74 1d 00 lea esi,[r13+rbx*1+0x0]
400519: 41 8d 3c 1c lea edi,[r12+rbx*1]
40051d: e8 db ff ff ff call 4004fd <_ZL3addRKiS0_.isra.0>
400522: 01 c3 add ebx,eax
400524: ff cd dec ebp
400526: 75 ec jne 400514 <_ZL4workii+0x13>
400528: 89 d8 mov eax,ebx
40052a: 5b pop rbx
40052b: 5d pop rbp
40052c: 41 5c pop r12
40052e: 41 5d pop r13
400530: c3 ret
Ответ 3
Я добавляю этот пост-признак, чтобы указать, что влияние выравнивания на общую производительность программ, включая большие, изучено. Например, в этой статье (и я полагаю, что версия этого также появилась в CACM) показывает, как только изменения порядка ссылок и изменения окружения ОС были достаточными для значительного изменения производительности. Они связывают это с выравниванием "горячих циклов".
Настоящая статья под названием "Изготовление неправильных данных без каких-либо очевидных ошибок!" говорит, что непреднамеренное экспериментальное смещение из-за почти неконтролируемых различий в среде работы программы, вероятно, делает многие результаты тестов бессмысленными.
Я думаю, что вы сталкиваетесь с другим углом зрения при одном и том же наблюдении.
Для критически важного кода это довольно хороший аргумент для систем, которые оценивают среду при установке или времени выполнения и выбирают местное лучшее среди различных оптимизированных версий ключевых подпрограмм.
Ответ 4
Я думаю, что вы можете получить тот же результат, что и вы:
Я схватил сборку за -O2 и объединил все ее отличия в сборке для -O, за исключением строк .p2align:
... используя -O2 -falign-functions=1 -falign-jumps=1 -falign-loops=1 -falign-labels=1
. Я собирал все с этими параметрами, которые были быстрее обычного -O2
каждый раз, когда я мешал измерять, в течение 15 лет.
Кроме того, для совершенно другого контекста (включая другой компилятор) я заметил, что ситуация аналогична: опция, которая должна "оптимизировать размер кода, а не чем скорость" оптимизирует размер и скорость кода.
Если я правильно понял, это paddings для выравнивания стека.
Нет, это не имеет никакого отношения к стеку, NOP, которые генерируются по умолчанию, а опции -falign - * = 1 для выравнивания кода.
В соответствии с тем, почему функция GCC работает с NOP? это делается в надежде, что код будет работать быстрее, но, по-видимому, эта оптимизация была неудачной в моем случае.
Является ли это прописью, которая является виновником в этом случае? Почему и как?
Очень вероятно, что простуда является виновником. Считается, что добавление причины является необходимым, и в некоторых случаях полезно, что код обычно выбирается в строках из 16 байтов (см. Ресурсы оптимизации Agner Fog для деталей, которые различаются по модели процессора). Выравнивание функции, цикла или метки на границе с 16 байтами означает, что шансы статистически увеличиваются, что потребуется меньшее количество строк, чтобы содержать функцию или цикл. Очевидно, что это вызывает неприятные последствия, поскольку эти NOP уменьшают плотность кода и, следовательно, эффективность кэширования. В случае циклов и меток NOP могут даже понадобиться выполнить один раз (когда выполнение выполняется в цикле/метке в обычном порядке, в отличие от перехода).
Ответ 5
Если ваша программа ограничена кешем CODE L1, тогда оптимизация размера начинает внезапно выплачиваться.
Когда последний раз я проверял, компилятор недостаточно умен, чтобы понять это во всех случаях.
В вашем случае -O3, вероятно, генерирует достаточно кода для двух строк кэша, но -O помещается в одну строку кэша.
Ответ 6
Я никоим образом не специалист в этой области, но, похоже, я помню, что современные процессоры довольно чувствительны, когда речь идет о прогнозе ветвления . Алгоритмы, используемые для прогнозирования ветвей, (или, по крайней мере, были в те дни, когда я написал код ассемблера), основываясь на нескольких свойствах кода, включая расстояние до цели и в направлении.
Сценарий, который приходит на ум, - это маленькие петли. Когда ветка двигалась в обратном направлении и расстояние не было слишком далеко, предсказание ветвления оптимизировалось для этого случая, так как все маленькие петли выполняются таким образом. Те же правила могут вступать в игру, когда вы меняете местами add
и work
в сгенерированном коде или когда позиция обеих изменений изменяется.
Тем не менее, я понятия не имею, как это проверить, и я просто хотел сообщить вам, что это может быть то, что вы хотите изучить.