Quant developer HFT course · Module 10: Numbers and time
How floating point really behaves
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.
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, soif (x < limit)with a NaN quietly passes the wrong branch. Risk checks on computed values should teststd::isfinitefirst. - Negative zero compares equal to zero but behaves differently in some operations (
1 / -0.0is −∞).
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?
Each literal is stored as the nearest binary value, and the addition rounds again. The two results are neighbouring doubles.
Question 2 of 3
Which comparison works for values of any magnitude?
A fixed absolute tolerance is too strict for large values and too loose for tiny ones. Relative error scales with the operands.
Question 3 of 3
A risk check computes notional < limit and notional is NaN because of a bad input. What happens?
Every ordered comparison with NaN is false. A check written as if (notional > limit) reject; lets the order through. Test std::isfinite first.
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.