Floating-point approximation, rounding, overflow and underflow
A floating-point format with a fixed number of bits can only hold a finite set of values, but there are infinitely many real numbers. Most real numbers therefore cannot be stored exactly: the computer stores the nearest value it can, and the difference is an error. This note explains where those errors come from, how they grow, and what happens when a number is too big (overflow) or too small (underflow) to be stored. Paper 3 asks you to explain these consequences, to calculate the error in a stored value, and to say how programmers reduce the problems.
Why binary is only an approximation
A denary fraction can be stored exactly in binary only if it is a sum of powers of 2 () that fits in the mantissa. is exact. But is not a finite sum of powers of 2. Repeatedly doubling the fractional part shows the pattern:
| Fraction | Bit | |
|---|---|---|
| 0.1 | 0.2 | 0 |
| 0.2 | 0.4 | 0 |
| 0.4 | 0.8 | 0 |
| 0.8 | 1.6 | 1 |
| 0.6 | 1.2 | 1 |
| 0.2 | 0.4 | 0 |
| 0.4 | 0.8 | 0 |
| ... | ... | the pattern 0011 repeats for ever |
So . Any finite mantissa must cut this off, so is always stored as an approximation. This is the same as in denary, which can never be written exactly with a finite number of decimal places.
A rounding error is the difference between a real number and the value actually stored for it, caused by the limited number of bits in the mantissa. The stored value is an approximation to the real number.
Represent as a normalised floating-point number with an 8-bit mantissa and a 4-bit exponent, truncating any bits that do not fit. Find the stored value and the error.
Solution
.
An 8-bit mantissa keeps 7 bits after the point: 0.1100110, so the mantissa is 01100110. Exponent = 1101.
Stored value: .
Error: (about 0.4% of the value).
The gaps between representable numbers
The values a floating-point format can represent are not evenly spaced. For a normalised number with an 8-bit mantissa, neighbouring mantissas differ by , so neighbouring stored values differ by :
| Exponent | Gap between neighbouring values | Typical numbers in this range |
|---|---|---|
| 64 to 127 | ||
| 1 to 2 | ||
| about 0.002 to 0.004 |
Large numbers are stored with large absolute gaps, small numbers with tiny ones. The relative precision (gap divided by value) stays about the same, which is the whole point of floating point. But it means, for example, that in this format is stored as : there is no representable value between 100 and 101.
Rounding errors in calculations
An error in one stored value might be small. The danger is what happens next.
- Errors accumulate. Every arithmetic result must itself be fitted back into the mantissa, adding a new small error. A loop that adds a thousand times adds a thousand small errors.
- Equality tests fail. Two calculations that should give the same answer may give values that differ in the last bit, so a test such as
IF Total = 1.0can be false when, mathematically, it is true. - Subtraction of nearly equal numbers cancels the significant bits that agree, leaving mostly error.
- Adding a very small number to a very large one can have no effect at all: if the small number is less than the gap at the large number's exponent, the sum rounds back to the large number.
Python uses 64-bit IEEE 754 floating point (53-bit mantissa), which has the same behaviour on a much finer scale:
total = 0.0
for _ in range(10):
total += 0.1
print(total) # 0.9999999999999999
print(total == 1.0) # False
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
import math
print(math.isclose(0.1 + 0.2, 0.3)) # True: compare within a tolerance
print(abs((0.1 + 0.2) - 0.3) < 1e-9) # True: the same idea by hand
print(sum([0.1] * 1000)) # 99.9999999999986, not 100
big = 1e16
print(big + 1 == big) # True: 1 is smaller than the gap at 1e16
Consequences of binary floating point being an approximation:
- Some denary values (0.1, 0.2, ) cannot be stored exactly.
- Stored values carry rounding errors, which can accumulate through repeated calculations.
- Real numbers should not be compared for exact equality; compare the absolute difference with a small tolerance.
- The more bits in the mantissa, the smaller the rounding error.
Reducing the problem
- Use more bits for the mantissa (double precision rather than single precision) to reduce the size of each rounding error.
- Store money as an integer number of the smallest unit (cents or pence), so all arithmetic is exact. A price of $12.10 becomes
1210cents. - Use a decimal or fixed-point type where exact denary fractions matter. Python's
decimal.Decimal("0.1")stores 0.1 exactly. - Never test reals for equality; test whether they are within a tolerance.
- Round only at the end of a calculation, and order calculations to avoid subtracting nearly equal values.
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3 exactly
print(Decimal(0.1)) # the float 0.1 really is
# 0.1000000000000000055511151231257827021181583404541015625
price_in_cents = 1210
total_cents = price_in_cents * 3
print(total_cents / 100) # 36.3, from exact integer arithmetic
Overflow and underflow
Overflow occurs when the result of a calculation is too large in magnitude to be represented: the exponent needed is larger than the largest exponent available.
Underflow occurs when the result of a calculation is too small in magnitude (too close to zero) to be represented: the exponent needed is more negative than the most negative exponent available. The result is usually stored as zero.
Both come from the exponent running out of range; rounding errors come from the mantissa running out of bits.
For an 8-bit mantissa and a 4-bit exponent, the largest positive value is , the most negative value is and the smallest positive normalised value is . So:
- needs exponent 8, but the largest exponent is 7: overflow.
- needs exponent , but the smallest exponent is : underflow, stored as 0.
What a system does on overflow varies: it may raise an error that stops the program, set a status flag, or store a special "infinity" value (IEEE 754 does this). Underflow is often silent, which makes it dangerous: a value that should be tiny but non-zero becomes exactly 0, and a later division by it fails.
print(1e308 * 10) # inf (overflow in IEEE 754 becomes infinity)
print(1e-320 / 1e10) # 0.0 (underflow: result too small, becomes zero)
Worked examples
Using an 8-bit mantissa and a 4-bit exponent, with bits that do not fit simply discarded, find the value stored for and the error.
Solution
. For : (1), (0), (0), (1), (1), ... so .
.
Keep 7 bits after the point: mantissa 01110011, exponent 0010.
Stored value: .
Error: .
A system uses an 8-bit mantissa and a 4-bit exponent, both two's complement and normalised. For each calculation, state whether the result can be stored, overflows or underflows.
(a) (b) (c) (d)
Solution
(a) . An exponent of 8 does not fit (maximum 7): overflow.
(b) : mantissa 10000000, exponent 0111. This is exactly the most negative value: can be stored.
(c) : mantissa 01000000, exponent 1000. Can be stored: it is the smallest positive normalised value.
(d) . An exponent of does not fit (minimum ): underflow; the result would be stored as 0.
Note (b): overflow is about magnitude and the asymmetry of two's complement. fits, but would not.
A program contains this pseudocode.
DECLARE X : REAL
X ← 0.0
REPEAT
X ← X + 0.1
OUTPUT X
UNTIL X = 1.0Explain why the loop might never end, and rewrite the condition so that it works.
Solution
cannot be represented exactly in binary, so each addition adds a value very slightly different from , and each result is itself rounded. After ten additions X holds a value very close to, but not exactly, (in IEEE double precision, ). The test X = 1.0 is false, X keeps growing past 1 and the condition is never met: an infinite loop.
Fix: test with a tolerance, or test a range.
UNTIL X >= 0.999999or, better, count with an integer and calculate X from it, so that no error accumulates:
DECLARE Count : INTEGER
FOR Count ← 1 TO 10
OUTPUT Count / 10
NEXT CountA shop's software stores prices as single-precision floating-point numbers. At the end of each day it adds the prices of several thousand sales, and the total sometimes differs from the till receipts by a cent.
(a) Explain why this happens. (b) Suggest two ways the developers could fix it.
Solution
(a) Most prices (such as 0.10 or 2.99) cannot be represented exactly in binary floating point, because their fractional parts are not finite sums of powers of 2; each is stored as the nearest approximation. Each addition produces a result that must again be rounded to fit the mantissa. Over thousands of additions these small rounding errors accumulate until the total is out by a whole cent.
(b) Store every price as an integer number of cents, so all additions are exact integer arithmetic, and only divide by 100 for display. Or use a decimal (fixed-point) data type that stores denary fractions exactly. (Using double precision reduces the error but does not remove it; on its own this is a weaker answer.)
In a format with an 8-bit mantissa and 4-bit exponent, explain why gives .
Solution
. With exponent 7, the last mantissa bit has value , so the gap between neighbouring representable values is 1.
lies between the representable values 100 and 101 and needs a bit worth , which the mantissa does not have at this exponent. The result is rounded (or truncated) back to 100, and the is lost entirely. The bigger the difference in size between two numbers, the more of the smaller one is lost.
- Overflow and underflow are caused by the exponent being out of range; rounding error is caused by the mantissa having too few bits. Do not mix them up.
- Underflow does not mean "a negative number that is too large"; that is overflow in the negative direction. Underflow is a magnitude too close to zero.
- "Use more bits" only reduces rounding error; it never removes it for values such as 0.1.
- "Explain the consequences of a binary representation only being an approximation" wants: the stored value differs from the real value (rounding error); errors can accumulate in repeated calculations; tests for equality may fail; results may be inaccurate.
- Use the precise language: rounding error, precision (mantissa), range (exponent), overflow (too large), underflow (too small, close to zero).
- When calculating an error, give the stored value in denary and the difference from the true value; show the binary.
- When asked how to avoid problems, integer storage of money and tolerance-based comparison are the answers examiners expect.
- Only a finite set of values can be stored; most real numbers (including 0.1) are stored as approximations.
- A rounding error is the difference between the true and stored values; more mantissa bits make it smaller.
- Rounding errors accumulate through repeated calculations and make equality tests on reals unreliable; compare within a tolerance.
- Gaps between representable values grow with the exponent, so adding a tiny number to a huge one can have no effect.
- Overflow: magnitude too large for the exponent. Underflow: magnitude too close to zero for the exponent, usually stored as 0.
- Use integers (cents) or decimal types when exact denary values matter.
Practice questions
- Explain why cannot be stored exactly as a binary floating-point number.
- Using an 8-bit mantissa and 4-bit exponent (truncating), find the stored value of and the error.
- Using an 8-bit mantissa and 4-bit exponent, find the stored value of and the error.
- Distinguish between overflow and underflow, giving an example of each for an 8-bit mantissa and 4-bit exponent.
- A program calculates the average of a list of real numbers and then checks
IF Average = 2.5 THEN. Explain why this check might fail even when the true average is 2.5, and rewrite it. - Explain why increasing the number of bits in the mantissa reduces, but does not eliminate, rounding errors.
- In an 8-bit mantissa, 4-bit exponent format, find the gap between neighbouring representable values (a) between 32 and 64, (b) between 0.25 and 0.5.
- A scientific simulation multiplies a small probability by itself many times. After a while the result becomes 0 and the program later crashes with a division-by-zero error. Explain what has happened and suggest a solution.
Answers
-
Converting the fractional part by repeated doubling: (0), (0), (1), (1), (0), and the pattern
0011repeats for ever: . A mantissa has a finite number of bits, so the expansion must be cut off and the stored value is only an approximation. -
. Mantissa
01001100, exponent1111. Stored value . Error . -
. Mantissa
01010100(7 bits after the point:1010100), exponent0011. Stored value . Error . -
Overflow: the result is too large in magnitude for the exponent to represent, for example , which needs exponent 8 when the maximum is 7. Underflow: the result is too close to zero for the exponent to represent, for example , which needs exponent (normalised mantissa ) when the minimum is ; it is stored as 0.
-
The values being averaged and the intermediate sum are stored with rounding errors, and the division adds another; the computed average may be, say, 2.4999999999 rather than 2.5, so the exact comparison is false. Rewrite:
IF ABS(Average - 2.5) < 0.00001 THEN(or the equivalent in code,abs(average - 2.5) < 1e-5). (Pseudocode has noABSbuilt-in in the guide; writeIF (Average - 2.5) < 0.00001 AND (2.5 - Average) < 0.00001 THEN.) -
Each extra mantissa bit halves the gap between neighbouring representable values, so the nearest stored value is closer to the true value and the maximum rounding error is halved. But numbers whose binary expansions never terminate (0.1, ) need infinitely many bits, so with any finite mantissa there is still some error.
-
(a) Numbers from 32 up to 64 have exponent 6 (mantissa ). Gap . (b) Numbers from 0.25 up to 0.5 have exponent . Gap .
-
Each multiplication makes the result smaller in magnitude. Eventually it needs an exponent more negative than the format allows: underflow, so it is stored as exactly 0. Later dividing by it causes the division-by-zero error. Solutions: use a format with more exponent bits (for example double precision) to extend the range; work with logarithms of the probabilities, adding them instead of multiplying; or check for underflow and handle it explicitly before dividing.