Why does Rust only use 16 significant digits for f64 equality checks?

Viewed 594

I have the following Rust code:

use std::f64::consts as f64;

fn main() {
    println!("Checking f64 PI...");
    // f64::PI definition: https://github.com/rust-lang/rust/blob/e1fc9ff4a794fb069d670dded1a66f05c86f3555/library/core/src/num/f64.rs#L240
    println!("Definition: pub const PI: f64 = 3.14159265358979323846264338327950288_f64;");
    println!("Print it:                       {:.35}", f64::PI);
    println!("Different after 16 significant digits ----------|                         ");
    println!("##############################################################################");
    println!("Question 1: Why do the digits differ after 16 significant digits when printed?");
    println!("##############################################################################");

    println!("PERFORM ASSERTIONS..."); 
    assert_eq!(f64::PI, 3.14159265358979323846264338327950288_f64); // 36 significant digits definition
    assert_eq!(f64::PI, 3.141592653589793_f64); // 16 significant digits (less then the 36 in definition)
    // compares up to here -------------|
    assert_eq!(f64::PI, 3.14159265358979300000000000000000000_f64); // 36 significant digits (16 used in equality comparison)
    assert_ne!(f64::PI, 3.14159265358979_f64); // 15 significant digits (not equal)

    println!("PERFORM EQUALITY CHECK..."); 
    if 3.14159265358979323846264338327950288_f64 == 3.14159265358979300000000000000000000_f64 {
        println!("BAD: floats considered equal even when they differ past 16 significant digits");
        println!("######################################################################");
        println!("Question 2: Why does equality checking use only 16 significant digits?");
        println!("They are defined using 36 significant digits so why can't we perform");
        println!("an equality check with this accuracy?");
        println!("######################################################################");
    } else {
        println!("GOOD: floats considered different when they differ past 16 significant digits");
        println!("NOTE: This block won't execute :(");
    }
}

I understand floating point arithmetic can be tricky but wanting to know if the trickiness also affects printing and performing equality checks on f64's. Here is the output from the above code:

Checking f64 PI...
Definition: pub const PI: f64 = 3.14159265358979323846264338327950288_f64;
Print it:                       3.14159265358979311599796346854418516
Different after 16 significant digits ----------|                         
##############################################################################
Question 1: Why do the digits differ after 16 significant digits when printed?
##############################################################################
PERFORM ASSERTIONS...
PERFORM EQUALITY CHECK...
BAD: floats considered equal even when they differ past 16 significant digits
######################################################################
Question 2: Why does equality checking use only 16 significant digits?
They are defined using 36 significant digits so why can't we perform
an equality check with this accuracy?
######################################################################
2 Answers

An f64, as the name suggests, is stored in 64 bits. In this fixed amount of storage we can only encode a fixed amounts of digits (specifically, 52 of those bits are dedicated to the significand). If you use more digits in your floating-point literal the number stored in your f64 variable will be rounded to the nearest number that is representable in the amount of bits available. For f64 this means we can always exactly represent 15 decimal digits, sometimes 16. This explains why sometimes numbers appear equal even though you used different floating-point literals in your source code: it's because after rounding to the nearest representable number, they are the same.

The reason for different digits being printed is the same. The number is rounded to the nearest representable number when stored, and converted back to decimal again when printed. The additional digits originate from the binary-to-decimal conversion, but the digits after 15 or 16 decimal places are mostly meaningless – they don't carry any additional information about the number being represented.

Note that none of this is specific to Rust. Most modern programming languages use the IEEE 754-1985 standard to represent floating-point numbers, so they will behave identically. If you want arbitrary-precision arithmetic, you generally need to use some library, e.g. the rug crate.

You are starting with the assumption that passing these all these double literals 3.14159265358979323846264338327950288_f64, 3.141592653589793_f64 or 3.14159265358979_f64 actually assigns these exact values to your variables. This assumption is incorrect.

Even though the authors of rust's source code used the first actual 36 digits of the mathematical constant to define f64::PI, the actual 64-bit value stored using the IEEE 754 floating point format is different. The closest IEEE 754 64-bit float value according to the online converter will be 0x400921FB54442D18, which can be approximated using the number 3.1415926535897931159979634685 when converted back to decimal. You get this same value when you convert the IEEE 754 value 0x400921FB54442D18 into a decimal number.

In other words:

What we wanted to store: 3.14159265358979323846264338327950288 
What is actually stored: 3.14159265358979311599796346854...

Perhaps a simpler way to visualize this would be to imagine having a fictional data-type which can store positive real numbers from 0 to 1, and is internally represented using a string (an array of chars), with a max length of 12 characters. So, you take this bizarre 96-bit type and create 5 variables:

strdouble A = 0.333333;       // internally stored as x = { .raw = "0.333333000" }
strdouble B = 0.333333333;    // internally stored as x = { .raw = "0.333333333" }
strdouble C = 0.333333333333; // internally stored as x = { .raw = "0.333333333" }
strdouble D = 0.333333333444; // internally stored as x = { .raw = "0.333333333" }
strdouble E = 0.333333333555; // internally stored as x = { .raw = "0.333333334" }

You can see that B, C and D will be equal, although the literal values passed to the compiler are quite different. You can also see how arithmetic like (1/3 + 1/3 + 1/3) would return 0.999999999 instead of 1, because there simply isn't a way to represent any precision beyond the last raw digit.

Related