Even though, IEEE-754 is a huge improvement compared to the floating point implementations that existed before, it still has many counter-intuitive properties.
0.1 is a repeating fraction.
In binary integers, each bit represents a power of two, like 20, 21, 22, etc. For example the binary number 1011 (read the digits from right to left) is equal to 1×20 + 1×21 + 0×22 + 1×23 =1 + 2 + 8 = 11 in decimal. Binary fractions have digits to the right of the binary point. These digits to the right represent values 2-1, 2-2, 2-3… etc, which means , , , … Thus, the digits after the binary point do not represent or . With binary fractions, we can exactly represent fractions like or but not .
The binary fraction for is 0.00011001100110011001100….. This is an infinitely repeating fraction, just like with decimal fractions the number is 0.333333…. No matter how many decimal places you have, it will never be exact.
For engineering applications this does not matter much. All measured and computed values are approximations of the real value anyway and floating point numbers are nearly always accurate enough. For accounting, this is a different story. We want the exact same totals on both sides of the balance sheet. And when we represent amounts with floating point numbers in a straightforward way (£1.36 is represented by the floating point number 1.36), we are not going to get it.
Real accounting programs should use integer maths (and using large integers of 64 bits or preferably 128 bits at that). Amounts are integers and percentages are computed with integer arithmetic and rounded correctly at each division by 100 or by 1000. Many people use spreadsheet programs to do their accounting and spreadsheets may or may not use decimal arithmetic when they think a column represents monetary amounts. Spreadsheets usually contain too many bugs to be taken seriously for these applications anyway.
It gets even worse when floating point computations are used to determine control flow. BASIC allowed you to index loops with floating point values. In the program below, it is not clear whether the loop will include the value 2.0 or not.
10 FOR A=0.1 TO 2.0 STEP 0.1
20 PRINT A
30 NEXT A
There is no problem, however if you use floating point values that are exactly representable. The loop below can be counted on to include the value 2.0.
10 FOR A=0.125 TO 2.0 STEP 0.125
20 PRINT A
30 NEXT A
There do exist decimal floating point libraries that represent decimal fractions exactly like you would expect and that can do decimal computations with exact results and correct rounding. Some CPU architectures (Power 9) implement decimal floating point in hardware, but this is not the case with x86, ARM or RISC-V.
Addition is not associative
Try to type the following lines of code in Python (similar code works in any programming language using floating point):
a = (0.1+0.2)+0.3
b = 0.1+(0.2+0.3)
print(a-b)
As you see, the values a and b are not equal! Whenever a floating point result is not mathematically exact, there is a chance that the final result depends on the way you group the operands.
The difference may be tiny in this case, but if the values have wildly different magnitudes and both positive and negative values are involved, the difference can be crucial. These are special algorithms to sum a large number of numbers in such a way that the output is as close to the correct answer as humanely possible.
Compilers should not rearrange floating point operations in an expression and they should not optimise away seemingly redundant operations. Those seemingly redundant operations may be there to implement a special trick to obtain a more accurate result than without them,
NaN is a weird thing
When you try to divide a positive number by zero, you get +infiinity as a result, when you divide a negative number by zero, you get -infinity instead. The same +infinity or -infinity can also result from ordinary overflow. When you divide 0 by 0, you get NaN, which means Not a Number. NaN would also result from subtracting infinity from infinity for example and in some other cases where the operation was clearly invalid. Every arithmetic operation you perform with one or both operands NaN, will result in NaN as well.
In a large simulation, some points in space may end up with a NaN value, while most of the space may still contain useful values. This is the reason why NaN results are propagated instead of stopping the whole simulation on an invalid operation.
NaN has some counter-intuitive properties:
- Every comparison against NaN will give a false result (except for the isNaN predicate). Therefore, if A is NaN, A<0 will be false, but A>=0 will be false too. A==A will even be false.
- When the MAX or MIN functions are used with one NaN and one proper number, the proper number is returned. This is one case, where NaN is not propagated if one operand is NaN
Rounding modes
Error analysis of floating point algorithms is hard. so hard that most programmers don’t even try to do it at all, let alone do it properly.
Rounding modes can be used to perform a crude form of error analysis. The IEEE-754 standard requires each implementation to support four rounding modes: round to -infinity, round to 0, round to +infinity and round to nearest. You can run the exact same computation in all four rounding modes and compare the results. The number of significant bits that agree in all four results, is a useful measure of the rounding errors in your computation. The more bits agree in all four results, the more reliable the result is. This is not bulletproof, but better than nothing.
There are cases where all precision is lost and the computed result is exactly zero, regardless of the rounding mode. However, the result should not have been zero. Comparing the results in different rounding modes, will not find the problem in this case.
Leave a Reply