Why optimization matters

When you build a C++ program, the compiler does more than just translate your code into machine instructions. Modern compilers actively improve your code's performance through optimization - the process of making software run faster or use fewer resources.

Consider a video game that needs to render 60 frames per second. Without optimization, calculations might take too long, causing stuttering gameplay. With optimization, the same logic runs smoothly. The difference between a sluggish application and a responsive one often comes down to how well the code is optimized.

Two approaches to optimization

Manual optimization involves programmers analyzing their code to find inefficiencies. Tools called profilers measure how long different parts of a program take to execute, helping developers identify bottlenecks. Once found, programmers can apply high-impact fixes like choosing better algorithms or restructuring data access patterns.

Automatic optimization happens during compilation. The compiler analyzes your code and applies transformations to make it run faster. These transformations happen at a low level - reorganizing operations, simplifying expressions, and removing unnecessary work. You write clean, readable code; the compiler figures out how to make it fast.

Modern C++ compilers are optimizing compilers. They don't modify your source files - optimizations happen transparently during the build process.

Key Concept
Optimizing compilers free programmers to focus on clarity and correctness. You write code that humans can understand, and the compiler transforms it into code that machines execute efficiently.

Most compilers default to minimal or no optimization. Debug builds typically disable optimizations (making debugging easier), while release builds enable them (maximizing performance). You control optimization through compiler flags or IDE build configurations.

The as-if rule

How much freedom does a compiler have to change your program? Quite a lot, actually.

The as-if rule states that compilers can transform your program in any way they choose, as long as the observable behavior remains unchanged. If the output, side effects, and timing-sensitive operations stay the same, the compiler can restructure everything else.

This means the compiler might:

  • Reorder operations that don't depend on each other
  • Replace complex expressions with simpler equivalents
  • Remove code that doesn't affect the result
  • Compute values at compile-time instead of runtime

Advanced note: There's one exception - the compiler can skip copy and move constructor calls in certain situations, even if those constructors have side effects you could observe.

Moving work from runtime to compile-time

Here's a simple program:

#include <iostream>

int main()
{
    int playerScore { 50 + 75 };
    std::cout << playerScore << '\n';

    return 0;
}

Output:

125

Without optimization, this program would calculate 50 + 75 every time it runs. If you run the program a thousand times, that addition happens a thousand times - always producing 125.

Since both operands are known when we compile the program, why wait until runtime to do the math? The compiler can perform this calculation once during compilation, embedding the result directly into the executable. This is compile-time evaluation - shifting work from when the program runs to when it's built.

The benefits are twofold: the program runs faster (no runtime calculation needed), and the executable is smaller (no addition instruction required). The only cost is slightly longer compilation, which happens once and is usually imperceptible.

Constant folding

Constant folding is an optimization where the compiler evaluates expressions with known values and replaces them with the result.

When the compiler sees 50 + 75, it recognizes that both values are fixed. Rather than generating code to add these numbers at runtime, it substitutes the result:

#include <iostream>

int main()
{
    int playerScore { 125 };  // compiler calculated this
    std::cout << playerScore << '\n';

    return 0;
}

This transformation is invisible to you - your source file still shows 50 + 75, but the compiled program works with 125 directly.

Constant folding works on subexpressions too:

#include <iostream>

int main()
{
    int baseScore { 100 };
    std::cout << 25 * 4 << '\n';  // subexpression 25 * 4 can be folded

    return 0;
}

The compiler optimizes this to std::cout << 100 << '\n';, even though the overall statement involves runtime I/O operations.

Constant propagation

Consider what happens after constant folding:

#include <iostream>

int main()
{
    int playerScore { 125 };
    std::cout << playerScore << '\n';

    return 0;
}

When this runs without optimization, the value 125 gets stored in memory for playerScore. Then, to print it, the program reads that value back from memory. That's a write and a read - two memory operations for a value that never changes.

Constant propagation lets the compiler substitute a variable's value directly where it's used, when the compiler can prove the value never changes:

#include <iostream>

int main()
{
    int playerScore { 125 };
    std::cout << 125 << '\n';  // value substituted directly

    return 0;
}

Now the memory fetch is eliminated. The program still allocates playerScore, but printing uses the literal value instead.

Constant propagation often enables further constant folding:

#include <iostream>

int main()
{
    int basePoints { 80 };
    int bonusPoints { 20 };
    std::cout << basePoints + bonusPoints << '\n';

    return 0;
}

First, constant propagation transforms basePoints + bonusPoints into 80 + 20. Then constant folding reduces that to 100.

Dead code elimination

After constant propagation, variables sometimes become unused:

#include <iostream>

int main()
{
    int playerScore { 125 };
    std::cout << 125 << '\n';  // playerScore is no longer referenced

    return 0;
}

The variable playerScore is initialized but never actually used - the print statement uses the literal 125 directly. This is dead code: it executes but doesn't affect the program's observable behavior.

Dead code elimination removes such code entirely:

#include <iostream>

int main()
{
    std::cout << 125 << '\n';

    return 0;
}

When a variable disappears this way, we say it has been optimized out or optimized away.

Look at the transformation from our original program:

// Original
int playerScore { 50 + 75 };
std::cout << playerScore << '\n';

// After all optimizations
std::cout << 125 << '\n';

The optimized version eliminates the runtime addition and all memory operations related to playerScore. Same output, less work.

Helping the compiler optimize

Compilers are powerful, but they're not omniscient. Sometimes small changes in how you write code make optimization easier.

Consider constant propagation with a non-const variable:

#include <iostream>

int main()
{
    int health { 100 };
    std::cout << health << '\n';

    return 0;
}

For constant propagation to work, the compiler must prove that health never changes. Since health is a regular variable, it could change - the compiler has to analyze the entire scope to confirm it doesn't. Depending on program complexity and compiler sophistication, this analysis might fail.

Make the intent explicit with const:

#include <iostream>

int main()
{
    const int health { 100 };  // guaranteed not to change
    std::cout << health << '\n';

    return 0;
}

Now the compiler knows health cannot be modified after initialization. Constant propagation becomes straightforward, and the variable will likely be optimized out entirely.

Best Practice
Declare variables as const whenever their values shouldn't change. Beyond preventing accidental modifications, this helps the compiler optimize more effectively.

Why optimization is disabled for debugging

If optimization improves performance, why not always enable it?

Optimized code can behave strangely in a debugger. Variables get optimized out, making them impossible to inspect. Function calls get inlined or eliminated, so stepping through code skips unexpectedly. Expressions get reordered, so execution doesn't follow source code line by line.

At runtime, debugging optimized code means debugging something that barely resembles your source. At compile-time, if an optimization produces incorrect results, tracking down the problem is genuinely difficult - you have limited visibility into what transformations the compiler applied.

Debug builds disable optimization to keep compiled code aligned with source code. When you're hunting bugs, predictable behavior matters more than speed.

Note: Compile-time debugging remains an evolving area. Future C++ standards may introduce better tools for understanding and debugging compile-time evaluation.

Compile-time vs runtime constants

You'll sometimes encounter these informal terms:

A compile-time constant is a constant whose value the compiler knows during compilation:

  • Literals like 42 or 3.14
  • Const variables initialized with compile-time constant values

A runtime constant is a constant whose value is only known when the program runs:

  • Const function parameters (the caller provides the value)
  • Const variables initialized from non-const sources or function calls
#include <iostream>

int getValue()
{
    return 50;
}

void process(const int amount)  // amount is a runtime constant
{
    std::cout << amount << '\n';
}

int main()
{
    // Non-constants:
    [[maybe_unused]] int score { 100 };

    // Compile-time constants:
    [[maybe_unused]] const int maxLevel { 99 };
    [[maybe_unused]] const double gravity { 9.81 };
    [[maybe_unused]] const int levelCap { maxLevel };  // initialized from compile-time constant

    // Runtime constants:
    [[maybe_unused]] const int currentScore { score };     // initialized from non-const
    [[maybe_unused]] const int savedScore { currentScore }; // initialized from runtime constant
    [[maybe_unused]] const int loaded { getValue() };       // function return value unknown at compile-time
    [[maybe_unused]] const int processed { process(100), 0 }; // involves runtime function call

    return 0;
}

These categories have limitations in practice:

  • The as-if rule lets compilers evaluate some runtime constants (and even non-constants) at compile-time when beneficial
  • Some compile-time constants (like const double gravity { 9.81 };) can't be used in all compile-time contexts defined by the language standard

We'll explore more precise terminology in the next lesson on constant expressions.

Summary

  • Optimization transforms code to run faster or use fewer resources, without changing observable behavior
  • The as-if rule grants compilers freedom to restructure programs as long as the output remains the same
  • Compile-time evaluation shifts calculations from runtime to compilation, producing faster, smaller executables
  • Constant folding replaces expressions with known operands with their computed results
  • Constant propagation substitutes variables with their values when those values are provably constant
  • Dead code elimination removes code that doesn't affect program behavior
  • Const variables give the compiler guarantees that make optimization easier
  • Debug builds disable optimization to keep code debuggable; release builds enable it for performance
  • Compile-time constants have values known during compilation; runtime constants have values known only at execution

Write clear, maintainable code with const wherever appropriate. Let the compiler handle the low-level optimizations.