Module 19 — Floating-Point Arithmetic
Source: The Art of Computer Programming, Vol. 2, 3rd ed., §4.2 (§4.2.1 the operations, §4.2.2 the accuracy of floating-point arithmetic). Lab:
labs/module-19-float· Grade it:./grade 19Self-contained: you can complete the module without the book. If you own Vol. 2, read §4.2.1–§4.2.2 alongside; the lesson says where to look.
Real numbers are a mathematician's fiction: there are uncountably many of them
and your machine has 64 bits. Floating point is the engineering compromise
that lets those 64 bits stand in for the reals across sixty orders of
magnitude — and it is a leaky abstraction. It looks like arithmetic, it
mostly behaves like arithmetic, and then one day 0.1 + 0.2 != 0.3, a Patriot
missile misses its target, and your unit test comparing two floats with ==
fails on a different CPU. Knuth's answer, and ours, is not to fear floating
point but to analyze it: know exactly what each operation does, and carry an
error bound the way a chemist carries significant figures.
By the end of this module you will have built a working binary floating-point
system — pack/unpack, add, subtract, multiply, divide, all correctly rounded —
that agrees with your hardware f64 bit for bit, and you will have proved
the theorems that make numerical computing trustworthy: the fundamental
rounding bound, the non-associativity of +, and why Kahan's summation
recovers the bits everyone else throws away.
Companion exhibit — The Floating-Point Abyss. That Patriot missile is not a metaphor here: the Museum's Catastrophe Simulator lets you set the bits used to store
0.1and the hours of uptime, then watch the accumulated chopping error drift the clock until the interceptor misses — and flip chop to round to see the same precision, spent more wisely, save the day. It reproduces the real figures from the 1991 Dhahran failure ( s per tick, 0.34 s of drift over 100 hours) on live IEEE-754.
1. Positional fractions and the normalized form
Fix a base (we use ) and a precision (we use , matching IEEE 754 binary64). A floating-point number is a value
where is the sign, is an integer exponent, and is the significand (Knuth calls it the fraction), a -digit base- number. The "floating" point is that slides the radix point to wherever the value needs it: the same significant digits describe and .
Two different (f, e) pairs can denote the same value — — so we impose a canonical form. A nonzero number is normalized
when its leading significand digit is nonzero; in binary that means the leading
bit is 1. We store the 53-bit significand as an integer frac with its
leading 1 pinned at bit 52:
2^52 ≤ frac < 2^53, value = (-1)^sign · frac · 2^exp
so 2^exp is precisely the weight of the last significand bit — the unit
in the last place, or ulp. Two consecutive representable numbers of the
same exponent differ by exactly one ulp.
Why normalize? Three reasons, all of which you will feel in the lab:
- Uniqueness. Every nonzero value has exactly one representation, so equality and comparison are trivial.
- Maximum precision. A leading zero bit would waste one of your 53 bits of accuracy. Normalizing keeps all of them significant.
- A free bit. Because the leading bit is always 1, IEEE 754 doesn't even
store it — the "implicit leading bit" trick buys a 53rd bit of precision
from a 52-bit field. Our
from_f64restores that1;to_f64strips it.
Zero is special: it has no nonzero leading digit, so it can't be normalized.
We represent it as frac == 0, and keep the sign bit so that and are
distinguishable (they matter at the boundary of underflow and for ).
The classic cautionary example lives here already. One tenth has no finite
binary expansion: , the block 0011 repeating
forever, exactly as never terminates in decimal. So from_f64(0.1)
cannot equal ; it is the nearest 53-bit number to , off by less
than half a ulp. Every "floating-point is broken" bug report traces back to
this one fact.
2. The biased (excess) exponent
The exponent e can be negative, but hardware likes to compare and sort
numbers by looking at their raw bits as unsigned integers. IEEE stores a
biased exponent: an 11-bit field E holding E = e_ieee + 1023, where
e_ieee is the exponent of the value written as 1.fff… · 2^{e_ieee}. The
bias is the "excess" that makes every stored exponent
non-negative, so that (for numbers of the same sign) larger magnitude means a
larger bit pattern — integer comparison sorts floats.
Our model keeps a signed exp: i32 for clarity, but from_f64/to_f64 must
translate. If a binary64 has biased field E and 52 stored mantissa bits m,
then for a normal number
value = 1.m · 2^{E-1023} = (2^52 + m) · 2^{E-1023-52},
so our frac = 2^52 + m and our exp = E − 1023 − 52 = E − 1075. Going back,
E = exp + 1075 and m = frac − 2^52 = frac & (2^52 − 1). The three reserved
patterns — E = 0 (zero and subnormals) and E = 2047 ( and NaN) — mark the
edges of the representable world; we handle zero and treat the rest as out of
scope for this finite model.
3. Rounding: nearest, ties to even
When an exact result needs more than 53 bits, we must round. IEEE's default (and ours) is round to nearest, ties to even (RNE):
- round to the closest representable number;
- if exactly halfway, round to the one whose last bit is 0 (even).
To round correctly you cannot simply chop; you must know three things about the discarded tail, the celebrated guard / round / sticky bits:
- the guard/round bit : the first bit past the ones you keep;
- the sticky bit : the OR of all bits below the round bit — "is there anything else down there?"
Then, keeping value with last kept bit :
If you're below halfway → round down. If you're above
halfway → round up. If you're exactly halfway (a tie) → round
to even, i.e. up iff . Our round_wide computes exactly these from a
wide integer: the round bit is bit , the sticky is "any bit below
set", where is how many bits we're dropping.
Why ties to even? A no-drift argument. Suppose you always rounded ties up ("round half up", the schoolbook rule). Then over many operations the tiny half-ulp errors would all have the same sign and accumulate: sum a million numbers and your bias marches steadily away from the truth, growing like . Rounding ties to even sends half the ties up and half down (the last bit is equidistributed between 0 and 1 over generic data), so the errors are mean-zero and tend to cancel — the accumulated bias grows like rather than , by the same central-limit reasoning that makes a random walk stay near the origin. "To even" specifically (rather than randomly) also has the pleasant property that — rounding in two steps agrees with rounding once, so it composes without introducing double-rounding error. That is why every serious system defaults to it.
4. Algorithm A — addition (and subtraction) — §4.2.1
Adding two floats is not "add the significands." Their radix points are in different places (different exponents), so you must align first. Knuth's Algorithm A, in our binary dress:
A1. [Unpack.] Extract signs, exponents, significands of u and v.
A2. [Assume e_u ≥ e_v.] If not, swap u ↔ v so u has the larger exponent.
A3. [Set e ← e_u.] The result's tentative exponent.
A4. [Scale right.] Shift v's significand right by (e_u − e_v) places,
catching the bits that fall off in guard/round/sticky.
A5. [Add or subtract.] If signs agree, add the aligned significands; else
subtract the smaller from the larger; sign follows the
larger magnitude.
A6. [Normalize.] Renormalize the result (it may have grown a bit on carry
or lost many on cancellation) and round to p bits (step N).
The subtlety is A4. If is much smaller, shifting it right by (say) 200
places would need a 250-bit register. But you don't need every bit — you need
the value rounded to 53 bits, and rounding only cares about guard + round +
sticky. So once v has shifted past the round position, all its remaining bits
just OR into the sticky bit. Our implementation keeps a fixed 64-bit guard
window exact and collapses everything beyond it into sticky — provably enough,
because a v shifted more than 64 places cannot reach even the round bit.
Subtraction is just ; negate 's sign and call add.
Hand-trace: aligning and rounding
Let's add, in a toy -bit binary system (leading 1 explicit, RNE):
u = 1.011₂ · 2^3 = 1011₂ · 2^0 = 11
v = 1.101₂ · 2^0 = 1101₂ · 2^{-3} = 1.625
A2/A3. , so , shift = 3.
A4. Shift right 3 places, tracking the tail:
v significand: 1.101 (at 2^0)
aligned to 2^3: 0.001 101 → keep "0.001", tail bits "101"
guard r = 1, sticky t = (0 OR 1) = 1
A5. Same sign, add the kept parts at exponent 3:
1.011
+ 0.001
-------
1.100 with pending round bits r=1, t=1 (we are above halfway)
A6 (step N). → round up the last kept bit:
1.100 + 0.001 = 1.101 · 2^3 = 1101₂ · 2^0 = 13
The exact sum is ; the nearest 4-bit float is (the representable neighbours are and , and is closer to ). Round-to-nearest did its job. Notice how the guard and sticky bits, not the discarded value itself, drove the decision.
5. Algorithm M — multiplication — and division — §4.2.1
Multiplication is the easy one, because significands multiply cleanly:
M1. [Unpack.] sign ← s_u XOR s_v; e ← e_u + e_v.
M2. [Multiply.] The product of two 53-bit integers is at most 106 bits —
hold it exactly in a 128-bit register. No information lost.
M3. [Normalize.] Renormalize the ~106-bit product to 53 bits (step N) and
round. Exponents simply added; only the significand rounds.
Because the exact product fits in u128, multiplication is exact before
rounding — the single rounding at M3 makes it correctly rounded. Multiplying
by a power of two touches only the exponent: shifts exp by with
no rounding at all, a fact the tests check.
Division is the hard one, because a quotient of two finite binaries is generally not finite ( again). We compute enough bits and round:
q = ⌊ frac_u · 2^64 / frac_v ⌋, r = (frac_u · 2^64) mod frac_v.
Sixty-four extra low bits is far more than the two (round + sticky) that
rounding needs, and the remainder tells us there is more below — it
is the sticky bit. Then step N rounds at exponent . The
result is correctly rounded, matching hardware / to the last bit.
Hand-trace: 3.0 / 7.0 in binary64
(frac = 3·2^51, exp = −51), . The exact
quotient repeats forever. Long division produces
; normalized that's . Keep 53 bits, look at
bit 54 (the round bit) and the infinite tail (sticky = 1, since the pattern
never terminates), apply RNE. You get exactly f64::from_bits((3.0f64/7.0).to_bits()) —
which the lab asserts over 40000 random pairs.
6. Error analysis — the unit roundoff — §4.2.2
Here is the theorem that makes numerical analysis possible.
Definition. The unit roundoff is in the "distance
to the tie" convention, or (as we and much C use it) ,
the gap between and its successor. We take machine_epsilon() : the gap between and the next representable double, with
(the tie rounds to even). Note it is not the smallest
with — under round-to-nearest that threshold is
(exclusive): any , e.g. , already gives
.
Theorem (fundamental bound of floating-point arithmetic). Let be one of , let be representable, and suppose is in the normal range. Then the computed result satisfies
Proof sketch. returns the representable number nearest . Adjacent representables straddle at a spacing of one ulp of the result, so the nearest is within half a ulp: . For a normalized result , (the last bit's weight is at most of the value). Combine: , i.e. has . ∎
This " model" is the entire engine of rounding-error analysis: you carry one per operation and bound the product . The lab verifies the bound empirically and exactly using TwoSum — Knuth's trick that, for , computes the exact rounding error with no extra precision, so exactly (in the absence of overflow). You then check over 60000 random pairs.
Why floating-point addition is NOT associative
The per operation means the order of operations changes the answer. Concretely, with (half a ulp at 1):
(1 + h) + h : 1 + h ties to even → 1 ; then 1 + h ties again → 1.
1 + (h + h) : h + h = 2^{-52} = ulp(1) exactly ; 1 + ulp = 1 + 2^{-52}.
So but . Different answers from
the same three numbers. Addition is commutative but not associative; the
lesson (and every compiler's -ffast-math footgun) is that you may not freely
reorder floating-point sums. The lab reproduces this on both our Float and
hardware f64 — they disagree with associativity in exactly the same way.
Catastrophic cancellation
When you subtract two nearly-equal numbers, the leading bits cancel and the result is built entirely from the low, error-contaminated bits — the relative error can explode even though each operation obeyed the fundamental bound. recovers cleanly when is representable at 1's scale, but compute as with and you get 0: the tiny term was rounded away by the addition, and the subtraction has nothing left to reveal. The fix is never to form the cancelling difference — rearrange the algebra (e.g. ) so the catastrophic subtraction never happens. This is the single most important practical lesson of §4.2.2.
7. Kahan summation — buying back the lost bits
Summing numbers naively accumulates one rounding error per addition; in the worst case the total error grows like times the running magnitude. Kahan's compensated summation (1965) keeps a running correction that holds the low-order bits dropped by the previous addition and feeds them back:
s ← 0 ; c ← 0
for each x:
y ← x − c # add back what we lost last time
t ← s + y # rounds; loses the low part of y
c ← (t − s) − y # exactly MINUS the low part just lost (TwoSum!)
s ← t
return s
Why is exactly the lost bits (first-order proof). Look at the third
line. In exact real arithmetic ; the reason it isn't zero in
floating point is precisely that discarded some low bits of the
true . When (the usual case — you're adding small terms to a
large running total), is computed exactly (Sterbenz's lemma: the
difference of two floats within a factor of two of each other is exact, and
more generally the leading cancellation here is benign), so
evaluates to the exact difference . Thus holds, to first order in , exactly the rounding error of the
step — and subtracting it next iteration cancels that error. The upshot is a
total error bound of independent of n, versus
for naive. The lab makes this vivid: sum 0.1 a hundred thousand times and
Kahan lands on 10000 to the bit while naive drifts; sum and naive returns 0 (every 1 was swamped) while Kahan recovers all
ten thousand ones.
Kahan does about the flops per element (the lab's benchmark shows the constant factor), which is why it is a choice — but when accuracy matters it is nearly free next to the alternative of using double the precision.
8. Knuth's warning: a leaky abstraction
Knuth is blunt in §4.2: floating-point arithmetic resembles real arithmetic
but obeys different laws, and "the programmer who ignores this fact does so at
his peril." Associativity fails. Distributivity fails. x == y can be false for
two computations that are mathematically equal. The abstraction leaks, and the
only defence is analysis: know the error bound of every operation, and design
so the bounds stay small. That is the mindset this module trains.
9. Stage-by-stage lab guide
Open labs/module-19-float/src/lab.rs. Run ./grade 19; stages run in order,
stopping at the first failure. The model is value = (−1)^sign · frac · 2^exp
with frac a 53-bit significand, normalized to bit 52.
Stage 1 — Representation: pack, unpack, normalize
Implement new (raw pack), zero, normalize (shift the leading 1 to bit 52),
from_f64/to_f64 (the bias arithmetic of §2), ulp (return 2^exp), and
classify. Keep from_f64 and to_f64 exact inverses on the normal range —
that bit-for-bit round-trip is what lets every later stage cross-check against
hardware. The 0.1 test encodes the "not exactly 1/10, but within a ulp" fact
as an integer inequality |10·frac − 2^{-exp}| < 10, so no bignums needed.
Stage 2 — Addition and subtraction (Algorithm A)
Implement add and sub. Align to the larger exponent (A2–A4), keeping a
64-bit guard window and a sticky bit; combine by sign (A5); renormalize and
round (A6, RNE). The tests demand bit-exact agreement with f64 +/− — even
in the huge-plus-tiny case — plus the round-to-even ties, non-associativity,
and catastrophic-cancellation phenomena as concrete assertions.
Stage 3 — Multiplication and division (Algorithm M)
Implement mul (exact 106-bit product in u128, then round) and div (scaled
long division with the remainder as sticky). Bit-exact vs hardware * and /;
multiply-by-power-of-two exact; ; sign rules; and a should_panic on
division by zero ( is out of scope). The single rounding after an exact wide
product/quotient is the whole trick to correct rounding.
Stage 4 — Error analysis: ulps and compensated summation
Implement machine_epsilon (), ulp_f64, ulp_error, naive_sum,
kahan_sum. The tests verify the fundamental bound via TwoSum, the
threshold, and that Kahan crushes naive on two adversarial
inputs. This is where representation turns into numerical analysis.
10. Check your understanding
- Why can't
0.1be stored exactly in binary64, and how far from is the stored value? (Repeating expansion; within half a ulp = under .) - In , what are the guard, round, and sticky bits, and which way does RNE go? (Round bit 1, sticky 0 → a tie; last kept bit of is 0 (even) → round down → .)
- Give three floats with and explain in one sentence. (E.g. : the two half-ulps round away separately but survive when added first.)
- Why does multiplication need only one rounding to be correctly rounded, while a naive division might need several? (The exact product fits in 106 bits; the exact quotient is infinite, so you must generate guard/round/sticky yourself.)
- In Kahan's loop, what does hold after the -th step, and why does subtracting it next time help? (The low bits lost when rounded; feeding them back cancels that step's error to first order.)
11. Exercises from the text
Ratings use Knuth's scale: 00 immediate · 10 a minute · 20 up to an hour · 30
hours · 40 term project · 50 research. ▶ marks especially instructive ones.
Each problem is on the material of the cited section. Log your work in
course/module-19-float/exercises.md.
| Source | Rating | Statement |
|---|---|---|
| cf. §4.2.1 | 10 | Give the normalized binary form of a few decimal fractions; which are exact? |
| ▶ cf. §4.2.1 | 20 | Show why the sticky bit (not just guard+round) is needed for correct rounding of addition. |
| cf. §4.2.1 | 22 | When can overflow even though and are well inside range? Analyze the exponent sum. |
| ▶ cf. §4.2.2 | 25 | Prove Sterbenz's lemma: if then is computed exactly. |
| cf. §4.2.2 | 28 | Bound the error of naive summation of terms; then Kahan's, showing the -independence. |
| ▶ cf. §4.2.2 | 30 | Analyze vs : characterize when they differ and by how much. |
Why it's done this way
- Normalized significand + separate exponent gives one representation per value (cheap comparison), maximum precision (no wasted leading zeros), and the implicit-leading-bit trick that buys a free bit — the whole reason IEEE packs 53 bits of precision into 52 stored.
- A biased exponent makes the raw bit pattern sort in numeric order, so hardware can compare floats with integer instructions.
- Round to nearest, even is the unique tie rule that is unbiased (errors cancel, drift is not ) and composes without double-rounding — which is why it is everyone's default.
- Guard/round/sticky is the minimum state that makes rounding correct after a shift or an infinite quotient; carry those three bits and you never need the whole discarded tail.
- Exact-then-round (106-bit products, 64-guard-bit quotients) is the clean road to correctly rounded operations: do the math exactly in a wider integer, round exactly once.
In the real world
- IEEE 754 is everywhere — every
f32/f64in every language, GPU, and phone implements exactly the pack/round/align machinery you just built. YourFloatagrees with it bit for bit precisely because the algorithm is the same. - The Patriot missile failure (Dhahran, 1991) killed 28 soldiers because the
system tracked time in tenths of a second, and
0.1's tiny representation error — the very fact from §1 — accumulated over 100 hours into a third of a second of clock drift, enough to miss an incoming Scud. Floating-point error analysis is not academic. - Kahan (and pairwise) summation lives in NumPy and pandas:
numpy.sumuses pairwise summation for exactly the accuracy reasons in §7, and libraries expose compensated variants (math.fsumin Python is fully exact). When youdf.mean()a million rows, someone did this analysis so you didn't drift. ==on floats is a bug in almost every context: two mathematically equal computations can differ in their last bits (non-associativity, different evaluation order, fused multiply-add). Compare with a tolerance, or compare the exact quantities you actually care about.- ML trains in bf16/fp16 — 8 and 11 significand bits, a tiny fraction of
binary64. It works only because the community re-derived §4.2.2 for low
precision: loss scaling to dodge underflow,
float32master weights, Kahan-style compensated optimizer accumulators, and stochastic rounding to keep gradient sums unbiased. Every one of those tricks is this module's theory applied where the ulps are enormous.
Proof techniques you practiced
- Nearest-point + spacing bound — the fundamental theorem falls out of "adjacent representables are one ulp apart," turned into .
- The model — replace each rounded operation by an exact one times , , then bound the accumulated product. The backbone of all rounding-error analysis.
- Exact error extraction (TwoSum/TwoProduct) — recover the rounding error itself in floating point with no extra precision; used both to verify the bound and to build Kahan summation.
- Bias/no-drift argument — the mean-zero, random-walk reasoning that makes round-to-even's error grow like instead of .
- Counterexample by construction — non-associativity proved by exhibiting and tracing both groupings.
- Invariant maintenance — Kahan's maintains "the bits lost so far," proved preserved each iteration (a loop invariant, as in Module 01).
12. Where this leads
- Interval and higher-precision arithmetic (double-double, the
TwoSum/TwoProduct"error-free transformations") build directly on stage 4 to get guaranteed bounds and extra digits from ordinary hardware. - Numerical linear algebra — the model is how you prove Gaussian elimination, QR, and iterative solvers are backward-stable; the conditioning of a problem times the stability of an algorithm bounds the error.
- The rest of Vol. 2 — §4.2.3 (double precision), §4.2.4 (distribution of floating-point numbers, and why leading digits obey Benford's law), and the polynomial and power-series evaluation of §4.6 all assume the fluency you built here.
- Reproducibility — deterministic, order-independent summation (superaccumulators) is an active systems topic in HPC and blockchain, and it starts from exactly the non-associativity you demonstrated.