Floating-Point Arithmetic (IEEE 754)

How IEEE 754 encodes reals in a fixed number of bits — exponent bias, normals and subnormals, signed zero, infinities and NaN — and what falls out of it: machine epsilon, five rounding modes with ties-to-even as default, non-associative addition, the exact bit-level reason 0.1 + 0.2 misses 0.3, FMA's single rounding, and the ~30-cycle cost of a subnormal on real silicon.

A double is not a real number with a bit of fuzz on the end. It is an exact rational — a 53-bit integer times a power of two — drawn from a finite set of 1.844×10191.844 \times 10^{19} values that are dense near zero and astronomically sparse near the top of the range. Every surprise in floating point follows from that one fact plus the rule that each operation returns the correctly rounded result of the exact operation on the exact inputs. IEEE 754 does not make arithmetic approximate; it makes the approximation reproducible, and this entry is about the machinery that gets you there.

The encoding #

A binary interchange format packs kk bits into three fields, in this order from the most significant bit:

text
 binary64 (double), k = 64
 ┌─┬───────────────┬────────────────────────────────────────────────────┐
 │S│  E (11 bits)  │            T (52 bits, trailing significand)        │
 └─┴───────────────┴────────────────────────────────────────────────────┘
  63 62           52 51                                                 0

The leading significand bit d0d_0 is not stored: it is implied by the exponent field, which is what buys the format one extra bit of precision. With pp the precision in bits, t=p−1t = p-1 the stored significand width, ww the exponent width, and EE the biased exponent, IEEE 754-2019 §3.4 defines the value:

v=(−1)S×2 E−bias×(1+2 1−p⋅T)for 1≤E≤2w−2v = (-1)^S \times 2^{\,E-\text{bias}} \times \left(1 + 2^{\,1-p} \cdot T\right) \qquad \text{for } 1 \le E \le 2^w - 2

v=(−1)S×2 emin⁡×(0+2 1−p⋅T)for E=0v = (-1)^S \times 2^{\,e_{\min}} \times \left(0 + 2^{\,1-p} \cdot T\right) \qquad \text{for } E = 0

Two exponent values are reserved and never mean "a normal number": E=0E = 0 encodes zero and the subnormals, and E=2w−1E = 2^w - 1 encodes the infinities (T=0T = 0) and the NaNs (T≠0T \ne 0). Everything in between is a normal number with an implicit leading 1.

Format parameters #

Values below are IEEE 754-2019 Table 3.5 (the parameters) with the derived limits computed from them. bfloat16 is not an IEEE interchange format — it is a vendor format that keeps binary32's exponent field and truncates the significand — but it is in the table because it is what most ML hardware actually stores.

Parameter binary16 binary32 binary64 binary128 bfloat16
kk storage bits 16 32 64 128 16
pp precision 11 24 53 113 8
ww exponent bits 5 8 11 15 8
t=p−1t = p-1 stored significand 10 23 52 112 7
bias 15 127 1023 16383 127
emax⁡e_{\max} 15 127 1023 16383 127
emin⁡=1−emax⁡e_{\min} = 1 - e_{\max} −14 −126 −1022 −16382 −126
ε=2−t\varepsilon = 2^{-t} 9.766e−4 1.192e−7 2.220e−16 1.926e−34 7.813e−3
max finite 65504 3.4028235e38 1.7976931e308 1.1897315e4932 3.3895314e38
min normal 6.1035e−5 1.1754944e−38 2.2250739e−308 3.3621031e−4932 1.1754944e−38
min subnormal 5.9605e−8 1.4012985e−45 4.9406565e−324 6.4751751e−4966 9.1835496e−41
decimal digits plog⁡102p\log_{10}2 3.31 7.22 15.95 34.02 2.41
round-trip digits 5 9 17 36 —

Two rows deserve a second look. bfloat16 and binary32 have identical dynamic range — same ww, same bias — which is the entire design argument: deep-learning training cares about range (gradients spanning many orders of magnitude) far more than about mantissa bits, so truncating binary32 to its top 16 bits is a free conversion (a shift) that never overflows where binary32 wouldn't. binary16 trades the other way: it has 3 more significand bits than bfloat16 but its max finite value is 65504, which real training runs hit.

And "double has 15–17 digits" is two different numbers. 53log⁡102=15.9553\log_{10}2 = 15.95 is how many decimal digits are always reproduced correctly; 17 is how many you must print to guarantee that reading the text back recovers the identical bit pattern. Goldberg gives the same split for binary32: 9 digits round-trip a single. Printing 15 digits and parsing them back is a lossy operation.

Decoding a real bit pattern #

0.1 as a double is 0x3FB999999999999A:

text
 S = 0
 E = 0x3FB = 01111111011₂ = 1019  →  e = 1019 − 1023 = −4
 T = 0x999999999999A
 significand = 1.1001100110011001100110011001100110011001100110011010₂
 value       = (nearest double to 1.6) × 2⁻⁴
             = 0.1000000000000000055511151231257827021181583404541015625

The stored value is not 0.1. It cannot be: 1/10 in binary is the repeating fraction 0.0001100110011…, and any finite binary significand truncates it. The nearest double is above 0.1 by about 5.55×10−185.55 \times 10^{-18}. This is the whole of the "floating point is inaccurate" folklore, and it is a base-conversion fact, not an arithmetic one — decimal and integer money types exist precisely because financial values are specified in base 10.

Normals, subnormals, and gradual underflow #

Biased EE TT Meaning
00 00 ±0\pm 0 (signed)
00 ≠0\ne 0 subnormal: (−1)S×2emin⁡×0.T(-1)^S \times 2^{e_{\min}} \times 0.T — implicit leading bit is 0
1…2w−21 \dots 2^w-2 any normal: (−1)S×2E−bias×1.T(-1)^S \times 2^{E-\text{bias}} \times 1.T
2w−12^w-1 00 ±∞\pm\infty
2w−12^w-1 ≠0\ne 0 NaN (d1=1d_1 = 1 → quiet, d1=0d_1 = 0 → signalling)

Without subnormals there would be a hole around zero 2522^{52} times wider than the local spacing: the smallest normal double is 2−1022≈2.225×10−3082^{-1022} \approx 2.225 \times 10^{-308}, adjacent normals just above it are 2−10742^{-1074} apart, and the next value below it would be zero. Subnormals fill that hole by giving up the implicit leading 1 and letting the significand slide right, trading precision for range one bit at a time down to 2−1074≈4.94×10−3242^{-1074} \approx 4.94 \times 10^{-324}.

Verify it directly — these are distinct subnormal doubles whose difference is representable:

c
double q = 0x1p-1070, r = 0x1p-1071;   // both subnormal
assert(q != r);
assert(q - r != 0.0);                   // 3.9525251667299724e-323, also subnormal

Precision: epsilon, ulp, and the sparsity of large numbers #

Watch the ulp grow. Bar length is logarithmic: width=8log⁡10(ulp/2−52)+8\text{width} = 8\log_{10}(\text{ulp}/2^{-52}) + 8 pixels.

Magnitudeulp (binary64)
12.22e−16
1031.14e−13
1061.16e−10
1091.19e−07
10121.22e−04
10150.125
10162
1018128

Above 253=9 007 199 254 740 9922^{53} = 9\,007\,199\,254\,740\,992 the ulp exceeds 1 and consecutive integers stop being representable: (double)(2^53 + 1) == (double)(2^53). This is the single most load-bearing number in practice.

Comparing with a fixed abs(a-b) < 1e-9 inherits this problem in the other direction: at magnitude 101210^{12} that tolerance is smaller than one ulp, so it is an equality test; at magnitude 10−1210^{-12} it accepts everything. Tolerances need to be relative, or expressed in ulps.

Rounding #

Every arithmetic operation in the standard is specified as: compute the exact result with unbounded range and precision, then round once to the destination format (§5.1). Five rounding-direction attributes exist (§4.3):

Attribute Behaviour Notes
roundTiesToEven nearest; on an exact tie, the candidate with an even last significand bit The default for all binary formats (§4.3.3)
roundTiesToAway nearest; on a tie, the larger magnitude Required for decimal formats, not required for binary
roundTowardPositive smallest representable ≥\ge exact result Interval arithmetic upper bound
roundTowardNegative largest representable ≤\le exact result Interval arithmetic lower bound; also flips the sign of exact zero sums to −0-0
roundTowardZero truncate toward 0 What C's float→int cast does

An implementation must provide roundTiesToEven plus the three directed attributes; roundTiesToAway is optional for binary. On x86 the mode lives in two bits of MXCSR (fesetround in C99); on AArch64 in FPCR.RMode.

Correct rounding is guaranteed for +, −, ×, ÷, sqrt, fma, remainder, and format conversions. It is not guaranteed for sin, exp, pow, or anything else in libm — accuracy there is implementation-defined, which is why the same program can produce different last bits on glibc versus musl versus the Apple libm.

Worked example: why 0.1 + 0.2 != 0.3 #

The usual explanation ("0.1 isn't exact") is only the first half. The interesting part is that the exact sum lands on a perfect tie, and ties-to-even pushes it away from 0.3.

Start with the two operands' significands, both 1.1001100110011001100110011001100110011001100110011010₂ (they are the same bits — 0.2 is 0.1 with the exponent one higher):

text
0.1 = 1.1001…1010₂ × 2⁻⁴      0x3FB999999999999A
0.2 = 1.1001…1010₂ × 2⁻³      0x3FC999999999999A

Align to 2−22^{-2} and add exactly. The exact sum is 10808639105689191/25510808639105689191 / 2^{55}, which as a significand times 2−22^{-2} needs 54 bits:

text
exact sum = 1.0011001100110011001100110011001100110011001100110011|1₂ × 2⁻²
                                                     53 kept bits ^ ^ round bit = 1, sticky = 0

Round bit 1 with nothing after it is an exact halfway case. The two candidates:

text
…0110011₂ × 2⁻²  = 0.29999999999999998889776975374843…   0x3FD3333333333333   (LSB 1, odd)
…0110100₂ × 2⁻²  = 0.30000000000000004440892098500626…   0x3FD3333333333334   (LSB 0, even)

roundTiesToEven takes the even one. Meanwhile the literal 0.3 is a separate base-conversion, and its nearest double is the odd candidate, 0x3FD3333333333333. So:

Note what this rules out: it is not that the answer is "a bit off". It is off by the smallest possible amount, in a direction fixed by the tie-break rule, and it is bit-for-bit identical on every conforming machine on earth.

Signed zero, infinities, NaN #

Signed zero #

+0+0 and −0-0 are distinct bit patterns (0x0000000000000000 and 0x8000000000000000) that compare equal (§5.11: "Comparisons shall ignore the sign of zero"). The sign is not decoration — it carries the direction from which underflow arrived, which preserves identities that would otherwise break:

c
1.0 /  0.0  ==  +inf
1.0 / -0.0  ==  -inf         // so 1/(1/x) == x survives x = ±inf
sqrt(-0.0)  ==  -0.0
atan2( 0.0, -1.0) ==  +3.14159…
atan2(-0.0, -1.0) ==  -3.14159…   // branch cut of the complex log/sqrt

§6.3 also fixes the sign of an exact-zero sum: x+(−x)x + (-x) is +0+0 under every rounding attribute except roundTowardNegative, where it is −0-0. And x + x and x - (-x) keep the sign of x under all of them.

Infinities #

Overflow under round-to-nearest produces ±∞\pm\infty rather than the largest finite value: §4.3.1 specifies that any exact result of magnitude at least 2emax⁡×(2−2−p)2^{e_{\max}} \times (2 - 2^{-p}) rounds to infinity. Infinity is a real value in the arithmetic, not an error state — 1/inf == 0, inf + 1 == inf, and inf - inf is NaN.

NaN #

A NaN is any E=2w−1E = 2^w - 1 pattern with T≠0T \ne 0. §6.2.1: the leading bit of TT (that is, d1d_1) distinguishes the two kinds — 1 for quiet, 0 for signalling — and the remaining t−1t-1 bits are the payload, free for diagnostic use. For binary64 that means 2512^{51} quiet and 251−12^{51}-1 signalling patterns per sign bit: roughly 9×10159 \times 10^{15} distinct NaNs, of which one, 0x7FF8000000000000, is the canonical quiet NaN most hardware produces.

Kind d1d_1 Behaviour
Quiet (qNaN) 1 Propagates through arithmetic silently; signals nothing
Signalling (sNaN) 0 (with some other TT bit set) Raises invalid operation on any general-computational op, then delivers a qNaN with d1d_1 set

Signalling NaNs exist for uninitialised-variable detection: fill memory with sNaNs and the first arithmetic use traps. In practice almost nothing uses them, because most languages quiet an sNaN as soon as a value is copied through a register on some platforms.

Non-associativity #

Floating-point addition is commutative but not associative, because each + rounds:

c
(1e16 + -1e16) + 1.0  ==  1.0
 1e16 + (-1e16 + 1.0) ==  0.0     // the 1.0 is absorbed: ulp(1e16) = 2

(1.0 + 1e100) + -1e100 ==  0.0    // the 1.0 is absorbed
 1.0 + (1e100 + -1e100) ==  1.0

Nor is it distributive, and x/10.0 is not x*0.1 (the second rounds twice: once converting the literal, once multiplying). The direct consequences:

  • Parallel reductions are not reproducible. A summation split across 8 threads and one split across 16 give different last bits, and re-running with a different thread count changes the answer. If bit-reproducibility matters, you need a fixed reduction tree, a deterministic order, or a compensated/exact accumulator — see rounding error and cancellation.
  • Vectorisation is a semantic change. Turning a serial sum into four SIMD lanes reassociates it. Compilers will not do this under plain -O2/-O3 for exactly that reason; -ffast-math / -Ofast / #pragma omp simd reduction explicitly grant permission.
  • Summation order matters. Adding a large accumulator to many small terms loses the small ones one at a time; sorting ascending, pairwise summation, or Kahan compensation all fix it to different degrees.

FMA: one rounding instead of two #

fusedMultiplyAdd(x, y, z) computes (x×y)+z(x \times y) + z "as if with unbounded range and precision, rounding only once to the destination format" (§5.4.1). Two consequences follow, and they pull in opposite directions.

It is more accurate. The full 2p2p-bit product survives into the addition instead of being truncated to pp bits first. A discriminant b2−4acb^2 - 4ac with a=c=1a = c = 1, b=2+2−31b = 2 + 2^{-31}:

c
double b = 2.0 + 0x1p-31, w = 4.0;
b*b - w        // 1.8626451492309570312e-09  == 2⁻²⁹ exactly — the 2⁻⁶² tail was lost in b*b
fma(b, b, -w)  // 1.8626451494477974657e-09  == 2⁻²⁹ + 2⁻⁶² — the exact answer

The naive form is wrong by a relative 2−33≈1.2×10−102^{-33} \approx 1.2 \times 10^{-10} — about six orders of magnitude worse than ε\varepsilon — because b2b^2 rounded away exactly the bits that the subtraction was going to expose. This is why FMA is the standard tool for determinants, discriminants, dot products, polynomial evaluation (Horner with FMA is both faster and more accurate), and Newton iterations for div/sqrt.

It gives you the error term for free. Since the product's rounding error is itself representable, one FMA extracts it exactly:

c
double p = x*y;
double e = fma(x, y, -p);   // p + e == x*y exactly, in double-double form

That is TwoProduct, the multiplication half of every double-double / compensated-arithmetic library. Verified: fma(0.1, 3.0, -(0.1*3.0)) is exactly −2−55-2^{-55}.

Subnormals cost real cycles #

Subnormals are handled by a slower path — sometimes a microcode assist — on essentially all x86 silicon. Wittmann et al. measured AVX double-precision throughput in cycles per scalar operation with FTZ+DAZ enabled ("F+D") versus disabled:

Operation / operandsSNB F+DSNB offIVB offHSW off
add, normal0.250.250.250.25
add, underflow0.2538.2037.7031.90
mul, normal0.250.250.250.13
mul, one subnormal operand0.2536.2035.7032.70
mul, underflow0.2540.0039.5032.70
div, normal10.3010.306.686.65
div, underflow11.0071.1063.3056.50
div, subnormal operand5.0464.3057.0054.00
FMA, addition underflow———33.50

Read the numbers, not the vibe. Overflow and NaN operands cost nothing on these cores — a NaN-poisoned array runs at full speed. The penalty is specifically subnormal inputs and underflowing results, and it is roughly two orders of magnitude: 0.25 → ~32–40 cycles for add/mul, 6.7 → ~57 for divide. Intel improved it across generations (38.2 → 37.7 → 31.9 cycles for an underflowing add from Sandy Bridge to Haswell), but did not remove it. Curiously, an underflow in the multiply half of an FMA costs nothing on Haswell, while an underflow in the addition half costs 33.5 cycles.

Two mode bits in MXCSR opt out: FTZ (bit 15) returns zero instead of a subnormal result, DAZ (bit 6) treats subnormal inputs as zero. Neither is IEEE 754 conforming. AArch64 has the equivalent in FPCR.FZ.

Practical rules #

Sources

  1. docsIEEE Std 754-2019 — IEEE Standard for Floating-Point Arithmetic
  2. paperWhat Every Computer Scientist Should Know About Floating-Point Arithmetic (Goldberg, ACM Computing Surveys 23(1), 1991)
  3. paperShort Note on Costs of Floating Point Operations on current x86-64 Architectures: Denormals, Overflow, Underflow, and Division by Zero (Wittmann, Zeiser, Hager, Wellein, arXiv:1506.03997)
  4. docsBFLOAT16 — Hardware Numerics Definition (Intel, doc 338302-001US)
  5. docsImprove your model's performance with bfloat16 (Google Cloud TPU documentation)
  6. docsJEP 306: Restore Always-Strict Floating-Point Semantics
  7. codeLLVM issue 81204 — FTZ/DAZ handling with -ffast-math targets is inconsistent for x86-64

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close