Skip to content
Work Practice Products Coding interviews Machine learning coding Quant developer HFT course Blog Method Why me About Enquire Book a call

Quant developer HFT course · Module 10: Numbers and time

How floating point really behaves

Lesson · 11 min

The question

Why does 0.1 + 0.2 == 0.3 evaluate to false in C++? What would you use to compare two doubles, and is a double safe for prices?

How a double is stored

A double is an IEEE 754 binary64 number: a sign bit, an 11-bit exponent and a 52-bit fraction.

doubles1 bitexponent11 bitsfraction (significand)52 bits
An IEEE 754 double: a sign, an 11-bit exponent and a 52-bit fraction. The value is ±1.fraction × 2^(exponent − 1023), so precision is relative: about 16 significant decimal digits at any magnitude.

The value is the significand (1 followed by the 52 fraction bits, in binary) times a power of two. That gives about 15 to 17 significant decimal digits at any magnitude: precision is relative, not absolute. Near 1 the gap between neighbouring doubles is about 2.2 × 10⁻¹⁶; near a billion it is about 1.2 × 10⁻⁷; near 2⁵³ (about 9 × 10¹⁵) it reaches 1, and odd integers above that cannot be represented at all.

Why 0.1 + 0.2 is not 0.3

0.1 in binary is a repeating fraction, 0.000110011001100…, just as 1/3 is 0.333… in decimal. A double stores the nearest representable value, which is very slightly more than 0.1. The same is true of 0.2 and 0.3. The sum of the stored 0.1 and the stored 0.2, rounded to the nearest double, is 0.30000000000000004, while the stored 0.3 is 0.29999999999999998. They differ in the last bit.

The general lesson: most decimal fractions are not exactly representable in binary, every operation rounds to the nearest representable result, and the errors from inputs and from each operation accumulate.

Comparing doubles

Exact == is right only when values are known to be exactly representable and computed exactly (small integers, or values copied without arithmetic). Otherwise, compare with a tolerance that has both a relative part, for large magnitudes, and an absolute part, for values near zero:

bool close(double a, double b, double rel = 1e-9, double abs = 1e-12) {
    return std::fabs(a - b) <= std::max(abs, rel * std::max(std::fabs(a), std::fabs(b)));
}

The right tolerances depend on the computation: how many operations, how well conditioned. A bare fabs(a - b) < 1e-9 works for values near 1 and fails for prices in the millions or quantities near 1e-12.

Special values

  • Infinity results from overflow or dividing a nonzero number by zero.
  • NaN (not a number) results from 0/0, ∞ − ∞, the square root of a negative number. Every comparison involving NaN is false, including x == x, so if (x < limit) with a NaN quietly passes the wrong branch. Risk checks on computed values should test std::isfinite first.
  • Negative zero compares equal to zero but behaves differently in some operations (1 / -0.0 is −∞).

Denormals: a performance trap

Numbers smaller than about 2.2 × 10⁻³⁰⁸ are stored as subnormal (denormal) values with reduced precision. On many x86 processors, arithmetic producing or consuming them takes a slow microcode path, tens to hundreds of times slower than normal operations. A filter or exponentially weighted average that decays towards zero can drift into denormals and suddenly run far slower. Setting the CPU's flush-to-zero and denormals-are-zero modes (FTZ and DAZ in the MXCSR register, which -ffast-math does at start-up on some compilers) treats them as zero and removes the cost.

-ffast-math

-ffast-math permits the compiler to assume no NaNs or infinities, to reassociate operations (so sums can be vectorised and reordered), to use reciprocal approximations, and, on some toolchains, to flush denormals. It can make numeric code much faster and can also change results and break NaN checks (std::isnan may be optimised away when the compiler assumes NaNs cannot occur). Teams that use it usually enable the specific sub-options they need, such as -fno-math-errno or -fassociative-math, rather than the whole bundle, and test results against a reference.

Doubles and prices

For the last part of the question: doubles are dangerous for prices because exchange prices are exact decimal multiples of a tick, and a double can neither store most of them exactly nor guarantee that arithmetic on them lands on a tick. Comparing a computed price with a level in the book, using prices as hash keys, or summing cash flows all go wrong in rare, hard-to-reproduce cases. The next lesson's integer representation is the standard answer. Doubles remain right for things that are not exact decimals: fair values, volatilities, signals, statistics.

Check yourself

Question 1 of 3

Why is 0.1 + 0.2 == 0.3 false for doubles?

Question 2 of 3

Which comparison works for values of any magnitude?

Question 3 of 3

A risk check computes notional < limit and notional is NaN because of a bad input. What happens?

In the interview

Say your own answer out loud first, then open the one we would give.

Why can't 0.1 be represented exactly as a double?

Doubles are binary fractions, and 0.1 has an infinite repeating expansion in binary, as 1/3 does in decimal. The stored value is the nearest representable double, slightly above 0.1, and arithmetic rounds again at every step.

How do you compare floating-point numbers?

With a tolerance that has a relative component for large magnitudes and an absolute floor near zero, chosen for the computation's expected error. Exact equality only where values are exactly representable and computed exactly. And check for NaN first, since every comparison with it is false.

What are denormals and why do they matter for latency?

Subnormal values below the smallest normal double, stored with reduced precision. Many x86 cores handle them on a slow microcode path, so code drifting towards zero, such as decaying averages, can suddenly slow by a large factor. Flush-to-zero and denormals-are-zero modes remove the cost.