как написанный код превращается в другой кодhow the code you wrote becomes different code
Компилятор не обязан выполнять код так, как он написан — он обязан лишь дать наблюдаемое поведение, эквивалентное «наивной» семантике (as-if rule). Между этими двумя вещами — десятки последовательных проходов-трансформаций над IR, которые и есть источник разрыва между «что я написал» и «что реально выполняется».A compiler isn't obligated to execute code the way it's written — it's only obligated to produce observable behavior equivalent to the "naive" semantics (the as-if rule). Between those two things lie dozens of sequential transformation passes over the IR, and that's exactly the source of the gap between "what I wrote" and "what actually runs."
Вычисление выражений с известными на этапе компиляции значениями заранее, ещё до запуска программы, плюс распространение известного значения переменной по всем местам её использования.Evaluating expressions with compile-time-known values ahead of time, before the program ever runs, plus propagating a variable's known value to every place it's used.
int x = 2 * 3 + 4; // компилятор кладёт в код сразу 10, а не инструкции умножения/сложения// the compiler emits 10 directly, not multiply/add instructions
Тело вызываемой функции подставляется прямо в место вызова вместо реального CALL/RET — устраняет накладные расходы вызова и, что важнее, открывает возможность для дальнейших оптимизаций уже в контексте вызывающего кода (constant folding через границу, которой формально больше нет).The called function's body is substituted directly at the call site instead of a real CALL/RET — this eliminates call overhead and, more importantly, opens the door to further optimizations in the context of the caller (constant folding across a boundary that formally no longer exists).
Если компилятор может статически доказать, что виртуальный вызов всегда разрешается в одну и ту же конкретную реализацию (например, класс финальный или единственный экземпляр создан в видимом коде), виртуальный вызов через vtable заменяется на прямой — устраняет косвенность и, как следствие, снова открывает inlining на месте, где раньше он был невозможен из-за динамической диспетчеризации.If the compiler can statically prove a virtual call always resolves to the same concrete implementation (say, the class is final, or the only instance ever created is visible in the code), the vtable-based virtual call is replaced with a direct one — this removes the indirection and, as a result, reopens inlining at a spot where it was previously impossible due to dynamic dispatch.
Удаление кода, результат которого доказуемо нигде не используется (в том числе — целых недостижимых веток), плюс семейство трансформаций циклов: loop unrolling (разворачивание для снижения накладных расходов на переход), loop-invariant code motion (вынос неизменного внутри цикла вычисления наружу), автовекторизация (замена скалярных операций на SIMD).Removing code whose result is provably never used anywhere (including entire unreachable branches), plus a family of loop transformations: loop unrolling (to reduce branch overhead), loop-invariant code motion (hoisting a computation that never changes inside the loop out of it), and auto-vectorization (replacing scalar operations with SIMD).
Обычные оптимизации видят только одну единицу трансляции за раз — функция из другого .cpp файла для них непрозрачна. LTO откладывает генерацию машинного кода до этапа линковки, сохраняя IR всех единиц трансляции, и позволяет inlining/devirtualization работать через границы файлов — ценой заметно более долгой сборки.Ordinary optimizations see only one translation unit at a time — a function from another .cpp file is opaque to them. LTO delays machine-code generation until the link step, keeps the IR of every translation unit around, and lets inlining/devirtualization work across file boundaries — at the cost of a noticeably slower build.
Двухпроходная схема: сначала собирается инструментированная сборка, прогоняется на реальных/представительных данных, компилятор записывает, какие ветки горячие, какие функции вызываются чаще; затем финальная сборка использует эту статистику, чтобы агрессивнее инлайнить горячие пути и размещать холодный код так, чтобы не засорять instruction cache.A two-pass scheme: an instrumented build is compiled first and run against real/representative data, with the compiler recording which branches are hot and which functions get called most; the final build then uses that profile data to inline hot paths more aggressively and place cold code so it doesn't pollute the instruction cache.
// без оптимизаций — код 1:1 соответствует исходнику,// no optimizations — the code matches source 1:1,
// быстрая сборка, лучшая отладка (переменные видны в дебаггере)// fast build, best debugging (variables are visible in the debugger)
// -O2: большинство безопасных по времени/размеру оптимизаций,// -O2: most optimizations safe on both time and size,
// стандарт для продакшн-сборок// the standard for production builds
// -O3: + агрессивная автовекторизация и loop unrolling,// -O3: + aggressive auto-vectorization and loop unrolling,
// иногда крупнее бинарник и не всегда быстрее на практике// sometimes a larger binary, not always faster in practice
Компилятор имеет право предполагать, что UB никогда не происходит, и удалять код, зависящий от этого предположения — классический пример: проверка if (this == nullptr) может быть целиком выброшена оптимизатором, потому что разыменование нулевого this формально уже было бы UB раньше, и компилятор считает эту ветку заведомо недостижимой.The compiler is entitled to assume UB never happens, and to remove code that depends on that assumption — the classic example: a check like if (this == nullptr) can be entirely deleted by the optimizer, because dereferencing a null this would formally already have been UB earlier, and the compiler treats that branch as provably unreachable.
Агрессивный loop unrolling и инлайнинг на -O3 увеличивают размер кода — если это раздувает бинарник настолько, что перестаёт помещаться в instruction cache, реальная производительность на конкретном железе может просесть по сравнению с более компактным -O2. Единственный надёжный способ узнать — измерить на целевом железе и реальной нагрузке, а не полагаться на номер флага.Aggressive loop unrolling and inlining at -O3 increase code size — if that bloats the binary enough to blow the instruction cache, real-world performance on specific hardware can actually regress compared to the more compact -O2. The only reliable way to know is to measure on the target hardware under real load, not to trust the flag number.
На -O2/-O3 отладчик часто не может показать значение локальной переменной («optimized out») — она могла целиком существовать только в регистре и быть уничтожена constant propagation/dead code elimination задолго до точки останова. Отсюда стандартная практика — отлаживать на -O0 или -Og (оптимизации, специально не мешающие отладке).At -O2/-O3, the debugger often can't show a local variable's value ("optimized out") — it may have existed purely in a register and been eliminated by constant propagation/dead code elimination long before the breakpoint. Hence the standard practice of debugging at -O0 or -Og (optimizations specifically designed not to interfere with debugging).