AnyLearn
All lessons

Cancellation: Where the Digits Actually Go

Every operation is correctly rounded, yet results can be wrong in the first digit. The reason is that subtracting nearly equal numbers is exact and still catastrophic: it does not create error, it promotes error that was already there. This lesson builds that mechanism and the summation techniques that recover the lost accuracy.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

The loophole in the guarantee

The previous lesson ended on a strong promise: every operation is correctly rounded, with a relative error of at most about 1.1 times 10 to the minus 16.

That promise is kept, always. And results can still be wrong in the first significant digit. Understanding how requires reading the guarantee precisely.

The bound is relative to the result. An operation producing 1000 may err by 10 to the minus 13; an operation producing 10 to the minus 8 may err by 10 to the minus 24. Both satisfy the bound.

Now consider what happens when a result is much smaller than its inputs. The absolute error inherited from those inputs was sized to them, but it is now sitting on top of a much smaller number. Relative to the new result, that same absolute error is enormous.

Nothing was violated. The error did not grow; the quantity it is measured against shrank. That single reframing explains nearly every numerical failure in practice.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. The loophole in the guarantee

The previous lesson ended on a strong promise: every operation is correctly rounded, with a relative error of at most about 1.1 times 10 to the minus 16.

That promise is kept, always. And results can still be wrong in the first significant digit. Understanding how requires reading the guarantee precisely.

The bound is relative to the result. An operation producing 1000 may err by 10 to the minus 13; an operation producing 10 to the minus 8 may err by 10 to the minus 24. Both satisfy the bound.

Now consider what happens when a result is much smaller than its inputs. The absolute error inherited from those inputs was sized to them, but it is now sitting on top of a much smaller number. Relative to the new result, that same absolute error is enormous.

Nothing was violated. The error did not grow; the quantity it is measured against shrank. That single reframing explains nearly every numerical failure in practice.

2. Catastrophic cancellation

Subtracting two nearly equal numbers is the canonical case, and it has a property that makes it genuinely counterintuitive.

When two doubles are close in magnitude, their difference is often computed exactly. No rounding occurs at all in that step.

And it is still the operation that destroys the answer. Suppose two quantities are each known to fifteen significant digits and agree in the first twelve. Their difference has three meaningful digits. The other twelve digits of the result are whatever the inaccuracies in the inputs happened to be.

So the subtraction faithfully computed the difference of two slightly wrong numbers, and in doing so promoted rounding error from the sixteenth digit to the fourth.

This is why it is called catastrophic cancellation and why the name is apt. The catastrophe is not in the subtraction, which behaved perfectly. It is that the leading digits, which were correct, cancelled and left only the trailing digits, which were not.

The practical rule: a subtraction of nearly equal quantities is a point where accuracy is lost irrecoverably, and no later step can restore it.

3. The quadratic formula, done twice

The standard demonstration is a formula everyone learned, applied to coefficients that expose it.

import math
a, b, c = 1.0, 1e8, 1.0        # roots near -1e8 and -1e-8

# textbook formula
root_naive  = (-b + math.sqrt(b*b - 4*a*c)) / (2*a)
# rationalised form: multiply through by the conjugate
root_stable = (2*c) / (-b - math.sqrt(b*b - 4*a*c))

root_naive   # -7.450580596923828e-09
root_stable  # -1e-08

The true root is almost exactly -1e-8. The textbook formula returns a value 25% wrong, in a calculation with no loop, no accumulation, and four correctly rounded operations.

The failure is one subtraction. With b at 1e8, the square root is very nearly b, so -b + sqrt(...) subtracts two numbers agreeing in about sixteen digits. Almost every correct digit cancels.

The fix changes no mathematics. Multiplying numerator and denominator by the conjugate turns the subtraction of nearly equal numbers into an addition of two same-signed numbers, where no cancellation can occur. Same root, algebraically identical expression, and one is usable while the other is not.

4. Where the digits go

The middle box is the one that trips people up. The subtraction introduces no error of its own, and it is still where the accuracy is lost.

That distinction matters practically, because it tells you where to look. Debugging a numerical problem by hunting for the operation that rounded badly finds nothing: every operation rounded correctly. The operation to find is the one whose result was much smaller than its inputs.

A useful diagnostic follows. Instrument a suspect computation to flag any subtraction where the result's magnitude is far below both operands. Those points are where precision is being spent, and there are usually very few of them.

The accompanying design rule is to restructure so that cancellation happens early, on inputs that are still exact, rather than late, on values that have accumulated error. Cancelling exact inputs is harmless; cancelling computed ones is not.

flowchart TD
A["Two inputs, 16 correct digits each"] --> B["They agree in the first 12 digits"]
B --> C["Subtract: computed exactly, no rounding"]
C --> D["Leading 12 digits cancel to zero"]
D --> E["Only the last 4 digits survive"]
E --> F["Those digits carried the input errors"]
F --> G["Result has ~4 meaningful digits, not 16"]

5. Summation loses accuracy differently

Adding many numbers fails for a related but distinct reason, and it is the most common numerical problem in ordinary code because every average and every total does it.

When a running total is much larger than the next addend, the addition rounds. In the extreme, the addend is smaller than the gap between representable doubles near the total, so it contributes nothing at all.

vals = [1.0] + [1e-16] * 10_000_000     # true sum: 1 + 1e-9

total = 0.0
for v in vals:
    total += v
total          # 1.0      every single addend was discarded

import math
math.fsum(vals)   # 1.000000001

Ten million contributions vanished. Each addition was correctly rounded, and the correctly rounded result of 1.0 + 1e-16 is 1.0, because 1e-16 is below machine epsilon relative to 1.

Note what is not happening: this is not error accumulating slowly across many operations. It is information being discarded completely, on every operation, because of the magnitude difference between the running total and the addend.

6. Compensated summation

The fix is to keep the discarded part rather than losing it. William Kahan published it as "Pracniques: further remarks on reducing truncation errors", Communications of the ACM, volume 8, issue 1, 1965, page 40.

The idea is to carry a second variable holding the part of the last addition that did not fit, and to subtract it from the next addend.

def kahan_sum(xs):
    total = 0.0
    c = 0.0                      # running compensation
    for x in xs:
        y = x - c                # apply what was lost last time
        t = total + y            # rounds, losing the low bits of y
        c = (t - total) - y      # recover exactly what was lost
        total = t
    return total

kahan_sum([1.0] + [1e-16] * 10_000_000)
# 1.000000001    the correct answer

The line computing c is the whole algorithm. (t - total) is what actually got added, and subtracting y gives the difference between what should have been added and what was, exactly, because that subtraction involves nearby quantities and is itself exact.

Cost is four operations per element instead of one, and the effect is roughly a doubling of the working precision. Alternatives exist: sorting by magnitude before summing helps, pairwise summation is nearly free and much better than naive, and exact algorithms such as Python's fsum are correct but slower.

7. Addition is not associative

A consequence that reaches beyond numerics: floating point addition is commutative but not associative.

x, y, z = 1e16, -1e16, 1.0
(x + y) + z     # 1.0
x + (y + z)     # 0.0

Both groupings are correctly rounded. In the second, y + z rounds back to -1e16 because 1.0 is below the spacing there, and the information is gone before the outer addition happens.

Three practical consequences follow, and each surprises someone regularly.

Compilers must not reassociate. Rewriting (a+b)+c as a+(b+c) changes results, so it is forbidden unless fast-math flags are enabled. Enabling them buys speed and gives up reproducibility.

Parallel reductions are non-deterministic. Summing across threads groups the additions by however the work was scheduled, so the same program on the same data can produce different results between runs. This is the usual explanation when a GPU computation is not bit-reproducible.

Test tolerances must reflect this. A test asserting that a parallel sum equals a serial one exactly will fail intermittently, and the flakiness is the arithmetic rather than the code.

8. The patterns worth recognising

PatternWhy it hurtsFix
a - b with a nearly equal to bLeading digits cancel, errors promotedRearrange algebraically to avoid it
(-b + sqrt(b*b-4ac))Cancellation when b dominatesUse the conjugate form for that root
Naive loop summationSmall addends vanish into a large totalKahan, pairwise, or exact summation
1 - cos(x) for small xcos(x) is nearly 1Use 2*sin(x/2)**2
log(1+x) for small x1+x loses x's low bitsUse log1p(x)
exp(x)-1 for small xResult nearly zero, inputs nearly 1Use expm1(x)

The last two rows explain why standard libraries ship functions that look redundant. log1p and expm1 exist precisely because the obvious expressions cancel, and they compute the intended value without ever forming the cancelling intermediate.

The unifying diagnostic across every row is the same question: is any intermediate result much smaller in magnitude than the values that produced it? Wherever the answer is yes, precision is being spent, and that is where to look first.

The next lesson turns to what follows from all of this at the comparison boundary, where these effects meet the equality operator.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why can a result be wrong in the first digit when every operation is correctly rounded?
    • Because the error bound is relative, so a result much smaller than its inputs carries an inherited error that is now large relative to it
    • Because correctly rounded means rounded toward zero
    • Because the guarantee does not apply to subtraction
    • Because errors accumulate additively across operations
  2. What is surprising about the subtraction in catastrophic cancellation?
    • It rounds twice, once for each operand
    • It is often computed exactly, introducing no error of its own
    • It always underflows to zero
    • It is the only operation not covered by IEEE 754
  3. The textbook quadratic formula returns a root 25% wrong. What fixes it?
    • Computing in higher precision throughout
    • Sorting the coefficients by magnitude
    • Using a different rounding mode
    • Multiplying by the conjugate, turning the cancelling subtraction into an addition of same-signed numbers
  4. A loop sums 1.0 followed by ten million values of 1e-16 and returns exactly 1.0. Why?
    • The loop overflowed the accumulator
    • 1e-16 is below machine epsilon relative to 1, so each correctly rounded addition returns the running total unchanged
    • The values underflowed to zero on load
    • Error accumulated until it cancelled the addends
  5. Why are parallel reductions not bit-reproducible?
    • Because threads use different rounding modes
    • Because atomic operations lose precision
    • Because addition is not associative, so a different grouping of the same additions gives a different result
    • Because floating point addition is not commutative

Related lessons

Computer Science
advanced

Conditioning and Stability: Whose Fault Is the Error

Two different things can make a numerical answer inaccurate: the problem may amplify any input perturbation, or the algorithm may introduce its own. They are separate quantities, one belongs to the problem and one to the code, and only the second is fixable. This lesson separates them and shows what follows.

8 steps·~12 min
Computer Science
advanced

Comparing Floats, and Testing Code That Uses Them

Equality fails on floats, and both standard replacements fail too: absolute tolerance breaks at scale, relative tolerance breaks near zero. This lesson works through why each fails, what the combined form actually does, and how to choose a tolerance from the problem rather than copying a magic constant.

8 steps·~12 min
Computer Science
intermediate

How a Float Is Stored, and What That Rules Out

Floating point is not a slightly inaccurate version of real numbers. It is a finite set of values, spaced logarithmically, with exact rules. This lesson builds the IEEE 754 layout, shows why 0.1 cannot be represented, and derives the one error bound that every later result depends on.

8 steps·~12 min
Programming
advanced

Refs: A Branch Is a File Containing a Hash

Hashes are unusable by hand, so Git names them. A branch is not a copy of anything, not a directory, and not a container for commits: it is a forty-character file. Once that is clear, checkout, reset, detached HEAD and the reflog stop being separate concepts and become one operation on one kind of file.

8 steps·~12 min