Avatar ·

Почему GCC генерирует на 15-20% более быстрый код при оптимизации на размер (-Os), а не на скорость (-O2)?

Всем привет! Я новичок в C++ и пытаюсь разобраться с флагами оптимизации GCC. У меня есть небольшая программа для обработки изображений: я применяю фильтр Гаусса к матрице пикселей (размер 4096x4096). Я компилирую её с разными флагами и замеряю время выполнения. Ожидаемо, -O2 даёт прирост по сравнению с -O0. Но я заметил странную вещь: с флагом -Os (оптимизация на размер кода) программа работает на 15-20% быстрее, чем с -O2 (оптимизация на скорость)! Вот упрощённый код: ```cpp #include #include #include void gaussian_blur(const std::vector& src, std::vector& dst, int width, int height) { const float kernel[5] = {0.0545, 0.2442, 0.4026, 0.2442, 0.0545}; for (int y = 0; y < height; ++y) { for (int x = 0; x < width; ++x) { float sum = 0.0f; for (int ky = -2; ky <= 2; ++ky) { for (int kx = -2; kx <= 2; ++kx) { int ix = std::clamp(x + kx, 0, width - 1); int iy = std::clamp(y + ky, 0, height - 1); sum += src[iy * width + ix] * kernel[ky + 2] * kernel[kx + 2]; } } dst[y * width + x] = sum; } } } int main() { const int W = 4096, H = 4096; std::vector src(W * H, 1.0f), dst(W * H); auto start = std::chrono::high_resolution_clock::now(); gaussian_blur(src, dst, W, H); auto end = std::chrono::high_resolution_clock::now(); std::cout << std::chrono::duration(end - start).count() << "s\n"; } ``` Компилирую так: `g++ -O2 main.cpp -o test_O2` и `g++ -Os main.cpp -o test_Os`. Результаты: -O2 ~2.1s, -Os ~1.7s. Почему так происходит? Я думал, что -O2 должен быть быстрее. Это баг GCC или я что-то не понимаю в оптимизации? Подскажите, пожалуйста! <<>>

Почему -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.

Итог: не всегда "оптимизация на скорость" даёт скорость. Экспериментируйте с флагами и профилируйте!