Floating Point
IEEE-754 trades the exactness of integers for enormous range by storing numbers as sign, exponent, and fraction — scientific notation in binary. We lay out the single and double formats, the bias that encodes the exponent, the three regimes (normalized, denormalized, special), a worked encode/decode, the four rounding modes and round-to-even at the bit level, why addition is not associative, the pitfalls of float-int conversion, and why 0.
╌╌╌╌
Integers are exact but cramped: a 32-bit int cannot reach a billionth or a
trillion. Floating point gains range by devoting bits to an exponent, storing
a number as a sign, a fractional significand, and a scale — binary scientific
notation. In exchange, most values are stored approximately, and the rules
governing that approximation are subtle. This lesson lays out
the IEEE-754 formats, decodes and encodes a concrete value, and explains the
rounding behavior that makes 0.1 inexact.
The IEEE-754 form
Every IEEE-754 number is built from three fields — a sign bit , an exponent field of bits, and a fraction field of bits — packed sign-first, exponent next, fraction last. The two standard widths fix and :
| Format | Total | Sign | Exponent | Fraction | Bias |
|---|---|---|---|---|---|
Single (float) | 32 | 1 | 8 | 23 | 127 |
Double (double) | 64 | 1 | 11 | 52 | 1023 |
The general value, when the number is normalized, is
Two design choices make this compact. The significand has an implied leading 1 that is never stored, gaining a free bit of precision. And the exponent is stored biased — as , an unsigned field — so that the bit patterns sort in the same order as the numbers, letting integer comparison hardware compare floats.
Three regimes: normalized, denormalized, special
The exponent field does double duty as a selector among three encodings. The all-zeros and all-ones patterns are reserved; everything between is normalized.
- Normalized ( neither all-0 nor all-1): the usual case above, with implied leading one, .
- Denormalized (): the leading digit becomes , so , and the exponent is fixed at . This represents zero (when , with a signed ) and the tiny values that fill the gap between zero and the smallest normalized number, giving gradual underflow.
- Special ( all-1): encodes (from overflow or division by zero); encodes NaN, the result of an undefined operation like or .
The denormalized region's purpose is continuity: without it there would be a sudden jump from the smallest normalized number straight to zero, and many small differences would flush to zero. Denormals fill that gap with evenly spaced values so if and only if . The numbers make the design visible in single precision. The smallest normalized value is ; the denormals below it march down in uniform steps of , from just under the boundary to the smallest positive value of all, . As a value decreases through this band it loses fraction bits to leading zeros, trading precision for range — gradual underflow, and the reason the newer IEEE vocabulary calls these values subnormal.
Density: precision is relative, not absolute
Fixed-point spacing is what integers have: neighbors sit exactly apart everywhere. Floating point instead keeps relative spacing constant. Within one binade the fraction patterns are evenly spaced apart, and stepping into the next binade doubles the gap. A toy format with three fraction bits shows the shape:
The same geometry at full width explains a fact worth remembering:
single precision represents every integer up to exactly,
then starts skipping. At the gap between neighbors reaches , so
has no float. A double, with fraction bits, holds
every integer through and then develops the same holes. Precision is
about one part in of the magnitude, wherever on the line the value
lives.
A worked decode and encode
Take the single-precision value . To encode it, first write it in binary: . Read off the three fields. The sign is (positive). The exponent is , so the stored field is . The fraction is the bits after the implied leading one: (23 bits, trailing zeros).
To decode in reverse, read , so ,
and . Then , recovering the original. The packed 32-bit word is 0x40D00000.
A tiny 8-bit toy format makes the three regimes visible in numbers small enough to check by hand. Give it one sign bit, a 4-bit exponent (bias ), and a 3-bit fraction. Three encodings from this format, one per regime, show the selector at work.
| Bits () | Regime | Value | ||
|---|---|---|---|---|
0 0110 100 | normalized | |||
0 0000 100 | denormalized | |||
0 1111 000 | special | — | — |
The denormal row is the instructive one: because pins the
exponent at (not ) and drops the implied
leading one, the values slide continuously down toward zero instead of leaving a
gap. Its neighbor 0 0000 001 decodes to , the smallest positive value this toy format can represent, and
0 0000 000 is . Line them up and the denormals fill the interval below the
smallest normal with evenly spaced steps of .
Rounding: four modes, one default
Most reals cannot be hit exactly, so every operation ends by rounding its true result to a representable neighbor. IEEE-754 defines four ways to choose the neighbor:
- Round-to-nearest-even (the default): pick the closer neighbor; on an exact tie, pick the one whose last fraction bit is .
- Round-toward-zero: truncate, dropping the excess bits. This is the mode C
uses for
float-to-intcasts. - Round-down (toward ) and round-up (toward ): the directed pair, useful for bracketing a true result between guaranteed bounds.
Rounding the decimal values , , , , and to integers separates the four:
| Mode | |||||
|---|---|---|---|---|---|
| Nearest even | |||||
| Toward zero | |||||
| Down | |||||
| Up |
At the bit level, round-to-even looks at the tail, the bits below the last position kept, and compares it against one half of the last kept unit, the pattern Rounding to two fraction bits (nearest quarter):
| Value | Tail against half | Result |
|---|---|---|
| , below half | ||
| , above half | ||
| exact tie, kept LSB is odd | ||
| exact tie, kept LSB is even |
Why bother with the even rule? Always rounding ties the same direction (say, up)
introduces a systematic drift across a long sum; ties to even go up half the time
and down half the time, and the bias cancels. The same principle in decimal is
the familiar round half to even
(banker's rounding): rounds to , but
and both round to .1
Rounding twice is not rounding once
A subtle corollary: rounding to an intermediate precision and then to the final one can give a different answer than rounding directly, even when every step is correctly rounded. Take and round it to one fraction bit. Directly, the tail is above half, so the result is . Now go through two fraction bits first: the tail rounds down to , and is an exact tie at one fraction bit, so the even rule picks . Direct rounding gives ; double rounding gives . The first rounding discarded the information that the value sat above the halfway point.
This occurs in real hardware. The x87 floating-point unit that predates x86-64's
vector registers computes everything in 80-bit extended precision and rounds
again when a result spills to a 64-bit double, so the same C expression could
yield different bits depending on when the compiler spilled. The same effect
occurs in float f = d1 + d2;, which rounds the sum to double and then rounds
that to float.2
Why 0.1 is not exact
A binary fraction can represent exactly only those values whose denominator is a power of two. The decimal has a factor of in its denominator, so in binary it is a repeating expansion, just as repeats in decimal:
Stored in a 23- or 52-bit fraction, this infinite tail is truncated and rounded,
so the value held is near but not equal to it. The stored double is
actually , which is why the classic test fails.
#include <stdio.h>
int main(void) {
double sum = 0.1 + 0.2;
printf("%.17f\n", sum); /* 0.30000000000000004 */
printf("%d\n", sum == 0.3); /* prints 0: NOT equal */
return 0;
}
Neither , , nor is exactly representable, and the rounding errors
in and do not cancel against the rounding error in the stored .
This is not a bug in any one machine; it is inherent to base-2 fractions, and the
practical rule is to never compare floats with ==, testing instead whether the
difference is below a small tolerance.3
Addition that refuses to associate
Because every operation rounds, algebraic identities that hold for reals fail for floats. The most consequential loss is associativity. Floating-point addition is still commutative, and it is monotonic, but the grouping of a sum changes its value:
float a = (3.14f + 1e10f) - 1e10f; /* 0.0f */
float b = 3.14f + (1e10f - 1e10f); /* 3.14f */
The mechanism is absorption, and it is visible at the bit level. To add two floats the hardware must first align their radix points, shifting the smaller operand's significand right by the difference in exponents. Here and , an exponent gap of about ; shifting a 24-bit significand right by slides every bit past the edge of the fraction, so the addition contributes nothing and the parenthesized sum is exactly . Subtracting then leaves . Group the other way and first, preserving intact.
The practical consequence: the order in which a program sums a list changes the answer, so compilers do not reorder floating-point arithmetic unless explicitly told correctness does not matter, and numerical code that adds values of wildly different magnitudes must choose the order that loses less.4
Crossing between int and float
C converts freely between the integer and floating families, and each direction has a failure mode worth knowing cold.
inttofloatrounds. Afloatfraction holds bits, so integers above are not all representable:16777217, which is , rounds to the even neighbor . Conversion todoubleis exact for everyint() but rounds for largelongvalues.floattointtruncates, rounding toward zero:3.9becomes3,-3.9becomes-3.- Out-of-range conversions have no answer. Casting
1e10for a NaN tointis undefined; x86-64 hardware returns theinteger indefinite
pattern0x80000000, which is , regardless of sign.
int big = 16777217; /* 2^24 + 1 */
float f = big; /* rounds to 16777216.0f */
int back = (int) f; /* 16777216: the round trip lost the 1 */
int t = (int) -3.9f; /* -3: truncation toward zero */
int boom = (int) 1e10f; /* undefined; x86-64 yields INT_MIN */
The round trip is the case that surfaces in real systems: an integer stored in a
floating type comes back changed once it crosses the fraction width,
for float and for double — the latter being why integer identifiers
silently lose precision past in every language whose only number type
is a double, with no warning at either conversion.5
The low-precision formats of machine learning
CS:APP covers the two IEEE-754 widths that existed when it was written, single and double. Machine learning has since driven a proliferation of narrower formats, and they are a direct application of everything above: each is the same sign-exponent-fraction layout, trading fraction bits (precision) against exponent bits (range) for a specific workload.
The IEEE half precision (fp16, 1/5/10) has a 5-bit exponent, so its range
tops out near — small enough that gradients in a deep network overflow
to infinity. Google's bfloat16 (1/8/7) keeps float's full 8-bit exponent
and its range, spending only 7 bits on the fraction; it deliberately sacrifices
precision to keep the dynamic range — the trade training tolerates and
overflow does not.6 Training is far more sensitive to
range (not overflowing) than to the last few bits of precision, so
truncating a float to its top 16 bits — which is all bfloat16 is — costs
almost nothing.
| Format | Bits | Exp | Frac | Max finite | Use |
|---|---|---|---|---|---|
| double | 64 | 11 | 52 | general | |
| float | 32 | 8 | 23 | general | |
| bfloat16 | 16 | 8 | 7 | ML training | |
| fp16 | 16 | 5 | 10 | ML inference | |
| fp8 (E4M3) | 8 | 4 | 3 | ML inference |
The trend has run all the way down to 8-bit floats, standardized by the OCP in
2023 in two flavors — E4M3 (more precision) and E5M2 (more range) — for the
weights and activations of large models, where storing a number in one byte
instead of four quarters the memory traffic that dominates inference cost.7
Each new format is the sign-exponent-fraction layout of
this lesson, re-partitioned. Reading the low-precision literature comes down to
two questions about any format: how many exponent bits (what is
its range and where does it overflow), and how many fraction bits (how coarse is
its spacing) — the same two knobs that separate float from double.
Floating point and integers cover the numbers; the final foundations lesson returns to the bits themselves as logical objects, in boolean algebra and bit manipulation.
Footnotes
- Bryant & O'Hallaron, CS:APP, §2.4.4 — Rounding: the four modes, round-to-nearest-even as the IEEE default, and why it avoids the statistical bias of always rounding ties one way. ↩
- Bryant & O'Hallaron, CS:APP, §2.4.6 — Floating Point in C: the x87 extended-precision registers and the double-rounding hazards of computing wider than the stored type. ↩
- Bryant & O'Hallaron, CS:APP, §2.4.2–2.4.6 — Floating-Point Representation and Operations: rounding error makes most decimal fractions inexact in binary, so equality tests on floats are unreliable. ↩
- Bryant & O'Hallaron, CS:APP, §2.4.5 — Floating-Point Operations: addition is commutative and monotonic but not associative, and multiplication does not distribute over addition. ↩
- Bryant & O'Hallaron, CS:APP, §2.4.6 — Floating Point in C: conversion rules among
int,float, anddouble, including rounding on int-to-float and truncation with undefined out-of-range behavior on float-to-int. ↩ - bfloat16 originates in Google's TPU work; the rationale that neural-network training needs
float's dynamic range far more than its precision, so the format keeps the 8-bit exponent and truncates the fraction to 7 bits, is documented in the TPU and TensorFlow numerics literature (e.g. Wang & Kanwar,BFloat16: The secret to high performance on Cloud TPUs,
2019). ↩ - Micikevicius et al.,
FP8 Formats for Deep Learning
(2022), and the Open Compute Project OCP 8-bit floating-point specification (2023): the two 8-bit formats E4M3 and E5M2 that trade one exponent bit for one fraction bit, adopted for the weights and activations of large models. ↩
╌╌ END ╌╌