Question

Why `+ 0.1f` Can Be Faster Than `+ 0` in C++: Compiler Optimization and Floating-Point Behavior

cppperformancevisual-studio-2010compilationfloating-point

Question

In Visual Studio 2010 SP1, why does the following C++ code run more than 10 times faster when it uses 0.1f and - 0.1f than when it uses + 0 and - 0, even though the two versions look almost identical?

const float x[16] = {
    1.1f, 1.2f, 1.3f, 1.4f,
    1.5f, 1.6f, 1.7f, 1.8f,
    1.9f, 2.0f, 2.1f, 2.2f,
    2.3f, 2.4f, 2.5f, 2.6f
};

const float z[16] = {
    1.123f, 1.234f, 1.345f, 156.467f,
    1.578f, 1.689f, 1.790f, 1.812f,
    1.923f, 2.034f, 2.145f, 2.256f,
    2.367f, 2.478f, 2.589f, 2.690f
};

float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0.1f;
        y[i] = y[i] - 0.1f;
    }
}

But this version is much slower:

const float x[16] = {
    1.1f, 1.2f, 1.3f, 1.4f,
    1.5f, 1.6f, 1.7f, 1.8f,
    1.9f, 2.0f, 2.1f, 2.2f,
    2.3f, 2.4f, 2.5f, 2.6f
};

const float z[16] = {
    1.123f, 1.234f, 1.345f, 156.467f,
    1.578f, 1.689f, 1.790f, 1.812f,
    1.923f, 2.034f, 2.145f, 2.256f,
    2.367f, 2.478f, 2.589f, 2.690f
};

float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0;
        y[i] = y[i] - 0;
    }
}

The code was compiled with optimization level /O2 and SSE2 enabled. The key question is: how can changing a value from 0.1f to 0 make the program much slower instead of faster?

Short Answer

By the end of this page, you will understand that performance differences like this often come from compiler optimizations, not just the raw cost of arithmetic operations. You will learn how compilers remove useless work, how floating-point expressions can change optimization opportunities, why benchmarks can accidentally measure optimized-away code, and how to write more reliable performance tests in C++.

Concept

In C++, the code you write is not necessarily the code the CPU executes. An optimizing compiler analyzes your program and may:

  • remove calculations that do not affect the final result
  • rearrange operations
  • keep values in registers instead of memory
  • vectorize loops using SIMD instructions such as SSE2
  • simplify expressions like x + 0 into just x

That sounds like + 0 should always be faster. But in benchmarks, the opposite can happen if simplification changes the compiler's strategy.

The core idea: the compiler optimizes the whole expression

These two lines:

y[i] = y[i] + 0;
y[i] = y[i] - 0;

are mathematically redundant. A compiler can often remove them completely.

But once they disappear, the loop becomes:

y[i] *= x[i];
y[i] /= z[i];

Now the compiler may generate a very different machine-code version of the loop. In particular:

  • it may stop using a vectorized path it used before
  • it may keep a strict dependency chain from one iteration to the next
  • it may expose the real cost of floating-point division
  • it may decide the loop's result is never used in a meaningful way and optimize differently

With 0.1f, the compiler cannot simply remove both operations in the same way, because floating-point arithmetic is not exact. In floating-point math:

(a + 0.1f) - 0.1f

is not guaranteed to be exactly equal to a due to rounding.

That means the compiler must preserve more of the computation unless it is allowed to make aggressive floating-point assumptions.

Why this matters

This is an important lesson for real programming:

  • Performance is often about what the compiler can prove, not what looks cheaper to a human.
  • Tiny source changes can trigger large differences in generated assembly.
  • Floating-point code is especially sensitive because exact equivalence is tricky.
  • Microbenchmarks are easy to get wrong if the result is unused or if optimizations remove important work.

So the surprising speed difference is usually not because 0.1f is inherently fast. It is because 0 changes the optimization behavior of the compiler.

Mental Model

Think of the compiler like a very aggressive assistant who tries to simplify your work before doing it.

  • If you write take a number, add zero, then subtract zero, the assistant says: “That changes nothing. I’ll remove those steps.”
  • If you write add 0.1f, then subtract 0.1f, the assistant says: “In floating-point math, those may not cancel exactly, so I should be careful.”

Now imagine two factory workflows:

  • In one workflow, the assistant removes some stations, which accidentally causes the remaining process to be organized less efficiently.
  • In the other workflow, the extra stations stay, but the whole line can be grouped and processed in batches.

Even though the second workflow looks like more work, the whole system can end up faster.

That is what often happens with optimized C++: a tiny source change can push the compiler into a completely different machine-code strategy.

Take Quiz

Syntax and Examples

Redundant expressions in C++

A compiler can often simplify arithmetic identities:

float a = value + 0.0f; // often simplified to value
float b = value * 1.0f; // often simplified to value

But with floating-point code, not every expression is safely removable under strict rules.

Example 1: integer arithmetic

int x = 10;
x = x + 0;
x = x - 0;

For integers, this is always equivalent to:

int x = 10;

The compiler can remove the extra operations.

Example 2: floating-point arithmetic

float x = 1.2345f;
x = x + 0.1f;
x = x - 0.1f;

This looks like it should do nothing, but floating-point numbers are stored with limited precision. So the intermediate result may be rounded.

That means this sequence is not always exactly the same as the original value.

Example 3: benchmarking trap

for (int i = 0; i < 1000000; ++i)
{
    value = value + 0;
}

If value is never used meaningfully afterward, the compiler may remove the whole loop.

A safer benchmark makes the result observable:

#include <iostream>

int main()
{
    volatile float value = 1.0f;

    for (int i = 0; i < 1000000; ++i)
    {
        value = value + 0.1f;
        value = value - 0.1f;
    }

    std::cout << value << '\n';
}

volatile is not ideal for all benchmarking, but it helps demonstrate how to prevent the compiler from discarding obviously unused work.

Important takeaway

When measuring performance, always ask:

  • Is the compiler removing the work?
  • Is the result actually used?
  • Did this source change trigger different vectorization or register allocation?

Step by Step Execution

Consider this smaller example:

float y = 2.0f;
float x = 3.0f;
float z = 4.0f;

y *= x;
y /= z;
y = y + 0;
y = y - 0;

Step-by-step at the source level

  1. y starts as 2.0f
  2. y *= x makes y = 6.0f
  3. y /= z makes y = 1.5f
  4. y = y + 0 keeps y = 1.5f
  5. y = y - 0 keeps y = 1.5f

So logically, the last two lines do nothing.

What the compiler may do

The compiler may transform the whole sequence into:

float y = (2.0f * 3.0f) / 4.0f;

or even directly into a constant if everything is known in advance.

Now compare with:

float y = 2.0f;
float x = 3.0f;
float z = 4.0f;

y *= x;
y /= z;
y = y + 0.1f;
y = y - 0.1f;

Step-by-step at the floating-point level

  1. y starts as 2.0f
  2. y *= x gives 6.0f
  3. y /= z gives 1.5f
  4. y = y + 0.1f gives approximately 1.6f
  5. y = y - 0.1f may or may not come back to exactly 1.5f

Because floating-point numbers are rounded, step 5 is not always a perfect inverse of step 4.

Why that affects speed

Once the compiler sees + 0 and - 0, it can delete them. That may leave a tight loop dominated by multiplication and division, especially division, which is relatively expensive.

With 0.1f, the compiler may keep extra instructions, but it may also choose a different instruction schedule or vectorization pattern. In old compilers like Visual Studio 2010, these choices could produce surprisingly large performance differences.

Real World Use Cases

This topic appears in real software whenever developers optimize numeric code or write benchmarks.

1. Game engines

Game loops perform many floating-point calculations for:

  • physics
  • animation
  • particle systems
  • camera movement

A small source-level change can affect SIMD optimization and frame time.

2. Image and signal processing

Operations on arrays of floats are common in:

  • audio filters
  • image transforms
  • video processing

Compilers often try to vectorize these loops. Tiny changes can enable or block that optimization.

3. Scientific and financial software

Floating-point precision rules matter because mathematically identical expressions may not be computationally identical.

Examples:

  • repeated rounding in simulations
  • numerical stability in matrix code
  • price calculations using the wrong numeric type

4. Benchmarking libraries and performance tests

Developers often create small benchmarks to compare algorithms. If the compiler removes or rewrites the work, the benchmark stops measuring what the developer intended.

5. Embedded and high-performance systems

When CPU time matters, teams inspect generated assembly or profiling results to verify whether the compiler produced:

  • scalar code
  • vectorized code
  • fused operations
  • redundant loads and stores
Take Quiz

Real Codebase Usage

In real projects, developers rarely rely on guessing what is faster. Instead, they use patterns that make optimization safer and performance measurements more trustworthy.

Common patterns

1. Make benchmark results observable

If a computed value is never used, the compiler may remove the computation.

float result = compute();
std::cout << result << '\n';

Or store it somewhere visible to the program.

2. Separate algorithm code from benchmark code

A common structure is:

float run_algorithm(const float* x, const float* z, float* y)
{
    for (int i = 0; i < 16; ++i)
    {
        y[i] *= x[i];
        y[i] /= z[i];
    }

    float sum = 0.0f;
    for (int i = 0; i < 16; ++i)
    {
        sum += y[i];
    }
    return sum;
}

Returning a value makes the work harder to discard.

3. Use profiling and assembly inspection

Developers often verify performance with:

  • profiler output
  • compiler optimization reports
  • generated assembly listings

This helps answer: Was the loop vectorized? Was it simplified away?

4. Be careful with floating-point assumptions

In some builds, developers enable fast-math-style optimizations for speed. In others, they keep strict IEEE behavior for correctness.

The choice affects whether expressions can be reassociated, canceled, or fused.

5. Prefer clear code first

Instead of writing tricky arithmetic to influence optimization, write clear expressions and let profiling guide changes.

Guard clause for performance investigations

A practical debugging habit is:

  • confirm correctness first
  • ensure output is used
  • benchmark release builds only
  • compare generated assembly
  • test on the target compiler and CPU

Common Mistakes

1. Benchmarking code whose result is never used

If the final value is ignored, the compiler may remove most or all of the loop.

Problematic example

for (int i = 0; i < 1000000; ++i)
{
    value *= 1.01f;
}

If value is never observed, this may not measure real work.

Better

for (int i = 0; i < 1000000; ++i)
{
    value *= 1.01f;
}

std::cout << value << '\n';

2. Assuming mathematically equivalent floating-point expressions are identical

This is false in many cases.

Example

float a = 0.3f;
float b = (a + 0.1f) - 0.1f;

b may not be bit-for-bit equal to a.

3. Assuming fewer source lines always means faster code

The compiler optimizes whole expressions. Removing one operation in source code can accidentally produce worse machine code.

4. Ignoring compiler version differences

Older compilers, including Visual Studio 2010, may optimize very differently from modern compilers.

A result seen in one compiler may disappear in another.

5. Forgetting that division is expensive

In loops like this:

y[i] *= x[i];
y[i] /= z[i];

the division often dominates cost. If a source change affects how divisions are scheduled or vectorized, performance can change a lot.

6. Mixing integer and floating-point literals carelessly

0 is an integer literal. 0.0f is a float literal.

C++ will convert as needed, but writing the intended type is clearer.

Clearer version

y[i] = y[i] + 0.0f;
y[i] = y[i] - 0.0f;

This still may optimize away, but it expresses the floating-point intent more clearly.

Comparisons

Related ideas compared

ConceptWhat it meansEffect on this example
+ 0 / - 0Identity operationsUsually removable by the compiler
+ 0.1f / - 0.1fNot exact inverses in floating-pointOften must be preserved under strict rules
Integer arithmeticExact for these identitiesEasier for compiler to simplify
Floating-point arithmeticRounded, limited precisionHarder to prove expressions are equivalent
Scalar codeOne value at a timeMay be slower for array-heavy loops
SIMD/vectorized codeMultiple values processed at onceCan make a huge speed difference
Source code costWhat humans seeOften misleading
Machine code costWhat actually runsWhat determines performance

0 vs 0.0f

ExpressionLiteral typeTypical meaning
0intInteger zero, converted if needed
0.0doubleDouble-precision floating-point zero
0.0ffloatSingle-precision floating-point zero

In this case, the main issue is not the conversion cost. The main issue is the optimization opportunity created by adding and subtracting zero.

Strict floating-point vs aggressive floating-point optimization

ModeCompiler freedomTradeoff
Strict / precisePreserves floating-point semantics carefullyMore predictable results, sometimes less optimization
Fast math / relaxed rulesAllows algebraic simplificationsFaster code, but results may differ slightly

The behavior in your example depends heavily on how much freedom the compiler has.

Take Quiz

Cheat Sheet

Quick reference

  • C++ compilers optimize expressions, not just individual lines.
  • x + 0 and x - 0 are usually removable.
  • (x + 0.1f) - 0.1f is not guaranteed to equal x exactly in floating-point math.
  • Floating-point arithmetic uses rounding, so algebraic identities may not hold exactly.
  • Tiny source changes can trigger major changes in:
    • vectorization
    • register allocation
    • instruction scheduling
    • loop elimination
  • Microbenchmarks are unreliable if the result is never used.

Safer benchmark checklist

// 1. Use Release mode
// 2. Ensure result is observable
// 3. Repeat enough times
// 4. Measure with a real timer/profiler
// 5. Inspect generated assembly if needed

Common identities the compiler may remove

x + 0
x - 0
x * 1
x / 1

But be careful with floating-point

These are not always safely interchangeable under strict semantics:

(x + a) - a
(x * a) / a

Practical rule

If a benchmark result seems surprising, check:

  • Was code optimized away?
  • Was the loop vectorized?
  • Is the result used afterward?
  • Are floating-point rules affecting optimization?

FAQ

Why would + 0 make code slower instead of faster?

Because + 0 may let the compiler generate a completely different optimized version of the loop. The slowdown usually comes from changed optimization strategy, not from the cost of adding zero.

Is + 0 always removed by the compiler in C++?

Usually yes, especially for simple expressions. But exact behavior depends on type, compiler, optimization level, and surrounding code.

Why doesn't + 0.1f and - 0.1f always cancel out?

Because floating-point values are rounded to limited precision. After + 0.1f, the result may not be represented exactly, so subtracting 0.1f may not restore the original bit pattern.

Does this happen only in Visual Studio 2010?

No. The general idea applies to many compilers, though the exact performance difference depends on compiler version, optimization settings, and CPU architecture.

Is floating-point division expensive?

Yes. Division is usually much slower than addition or multiplication, and compiler choices around division-heavy loops can have a big impact on speed.

How can I benchmark this kind of code correctly?

Use a release build, make sure the result is used, run enough iterations, and inspect generated assembly or use a profiler if the result looks suspicious.

Should I use volatile for benchmarking?

Only carefully. It can prevent some optimizations, but it also changes the code being measured. For serious benchmarking, dedicated benchmark frameworks are usually better.

What is the main lesson from this example?

Never assume source-level simplicity guarantees runtime speed. In optimized C++, compiler behavior often matters more than the apparent number of operations.

Take Quiz

Mini Project

Description

Build a small C++ benchmark that demonstrates how compiler optimizations can change the performance of a numeric loop. The project compares a loop that adds and subtracts zero with one that adds and subtracts 0.1f, then prints a checksum so the compiler cannot easily discard the work.

Goal

Create and run a benchmark that shows how small floating-point expression changes can affect optimization and execution speed.

Requirements

  • Write a C++ program with two benchmark functions: one using + 0.0f and - 0.0f, and one using + 0.1f and - 0.1f
  • Use arrays of float values and repeat the inner loop many times
  • Return or print a checksum from each benchmark so the calculations remain observable
  • Measure execution time using std::chrono
  • Compile in Release mode with optimizations enabled
Take Quiz

Keep learning

Related questions

View more