Почему GCC генерирует на 15-20% более быстрый код при оптимизации на размер (-Os), а не на скорость (-O2)?
Почему -Os может быть быстрее -O2: разбор на примере
Вы столкнулись с классическим эффектом, который известен многим опытным разработчикам. Дело не в баге, а в том, как GCC балансирует между размером кода и производительностью. -O2 включает множество агрессивных оптимизаций, таких как полная развёртка циклов (loop unrolling), векторизация (auto-vectorization) и инлайнинг. Эти оптимизации часто увеличивают размер исполняемого кода, что может привести к промахам кэша инструкций (I-cache) на современных процессорах. -Os, наоборот, старается минимизировать размер кода, что может улучшить локальность и снизить нагрузку на кэш.
В вашем примере с фильтром Гаусса -O2 может развернуть внутренние циклы (по kx и ky), создав много копий тела цикла. Это увеличивает размер горячего участка кода. Если он не помещается в L1 I-cache (32 КБ на современных x86-64), процессор тратит время на подгрузку инструкций из L2 или даже из памяти. -Os оставляет циклы компактными, что повышает вероятность их полного размещения в кэше.
Давайте посмотрим на ассемблерный код. Скомпилируем с флагом -S и сравним:
g++ -O2 -S main.cpp -o main_O2.s
g++ -Os -S main.cpp -o main_Os.s
Откройте оба файла и найдите метку gaussian_blur. Вы увидите, что в -O2 внутренние циклы могут быть развёрнуты, а в -Os — нет. Размер кода функции в -O2 может быть в 2-3 раза больше. Вот примерная разница (упрощённо):
- -O2: 1200 байт инструкций для функции (развёрнутые циклы, много mov/add/mul).
- -Os: 400 байт инструкций (компактные циклы с jmp).
Когда процессор выполняет код, он загружает инструкции в I-cache. Если размер горячего участка превышает размер кэша, начинаются "кэш-промахи". На современных CPU (например, Intel Core i7) L1 I-cache — 32 КБ, L2 — 256 КБ. Если код функции занимает 1.2 КБ, это не критично для одного вызова. Но в вашем случае функция вызывается один раз, и проблема может быть в другом: -O2 может генерировать больше инструкций для вычисления адресов (например, imul вместо lea), что увеличивает давление на конвейер.
Конкретно в вашем коде -O2 может векторизовать внутренние циклы с помощью SIMD-инструкций (SSE/AVX). Это даёт прирост для больших массивов, но если данные не выровнены или есть зависимости, векторизация может быть неэффективной. -Os избегает векторизации, используя скалярный код, который лучше предсказывается процессором.
Вот как можно проверить гипотезу: попробуйте добавить флаг -fno-unroll-loops к -O2:
g++ -O2 -fno-unroll-loops main.cpp -o test_O2_nounroll
Если время станет близким к -Os (например, 1.8s), значит, проблема в развёртке циклов. Также попробуйте -O2 -mno-sse — если время вырастет, значит, векторизация была полезна, но не в вашем случае.
Рекомендация: для числодробильных задач, где горячий код помещается в кэш, -O2 часто быстрее. Но если у вас большой объём кода с циклами, попробуйте -Os или профилируйте с perf для анализа кэш-промахов. Иногда комбинация -O2 -fno-unroll-loops даёт лучший результат.
Альтернативный взгляд: влияние на конвейер и предсказание ветвлений
Коллега выше верно указал на I-cache, но есть и другая причина: -O2 может генерировать код с большим количеством условных переходов и сложных инструкций, которые хуже предсказываются процессором. -Os стремится к простым циклам с минимальным количеством ветвлений, что улучшает работу конвейера.
В вашем коде std::clamp внутри циклов генерирует условные переходы (cmp + cmov или jmp). -O2 может заменить их на более дорогие инструкции в попытке ускорить выполнение, например, используя minss/maxss из SSE. Это может увеличить задержку (latency) на конвейере, так как эти инструкции требуют больше тактов. -Os оставит простые cmp и jle, которые хорошо предсказываются, если границы редко нарушаются (как в вашем случае — края изображения).
Попробуйте заменить std::clamp на ручную реализацию с тернарными операторами и скомпилировать с -O2:
int ix = (x + kx < 0) ? 0 : (x + kx >= width) ? width - 1 : x + kx;
Если время уменьшится, значит, проблема в том, как -O2 обрабатывает clamp. Также проверьте с флагом -march=native — он может улучшить -O2, активируя AVX2.
Итог: не всегда "оптимизация на скорость" даёт скорость. Экспериментируйте с флагами и профилируйте!
Ответов пока нет.
Войдите чтобы оставить ответ
Блоги