How does the % operator work with floating point numbers?

Hello. I am currently learning Rust.
I was looking at how the % operator works with floating point numbers in Playground.

I did this,

fn main() {
    println!("{}", 0.1024 % 0.01);
}

and I got the output.

Standard Output
0.002400000000000003

OK. I can see this.

fn main() {
    println!("{}", 0.1999 % 0.01);
}
Standard Output
0.00989999999999999

Good.

fn main() {
    println!("{}", 0.1200 % 0.01);
}
Standard Output
0.009999999999999993

What?

I expected the output to be 0 or its approximation. However, the results are as above.
As a test, I ran the same statement by the f32 type, and got the expected results.

fn main() {
    println!("{}", 0.1200_f32 % 0.01_f32);
}
Standard Output
0

What do these mean?

This is approximately 0.01 which equals to zero modulo 0.01.

This means that someone forgot that 0.1 and 0.01 don't really exist in Rust (and most other contemporary languages).

Straight from Wikipedia: for example, the decimal number 0.1 is not representable in binary floating-point of any finite precision; the exact binary representation would have a "1100" sequence continuing endlessly.

Once you recall that and realize that “simple” examples which you are trying to use here are not simple at all… I guess you would be able to realize what's happening and why. Try to use numbers like 0.5 or 0.625 — these would be easier to reason about.

P.S. That's one reason which explains why COBOL is so hard to replace in banks. COBOL (unlike most modern languages) do have 0.1 and it works like you would expect. Most other languages (Rust including) only have approximations.

2 Likes

When doing math modulo 0.01, the numbers zero and 0.01 become equal, so 0.0099999 is a very good approximation to zero.

3 Likes

Specifically what happens is that 0.12 is rounded down when converted to binary with f64 precision. With more precision it is really closer to 0.11999999999999999556.

You're dealing with numerical instability. Because the % operator is discontinuous, tiny rounding errors can become very big errors.

2 Likes

I think this could be nicer phrased as, "Maybe you are not aware …" or "Maybe you have missed/forgotten…".

3 Likes

The general problem is Catastrophic cancellation - Wikipedia

Check out https://float.exposed/0x3f847ae147ae147b -- it's a great way to see what's actually happening here.

0.12 is converted to its closest f64, which is actually *exactly*
0.11999999999999999555910790149937383830547332763671875

0.01 is converted to its closest f64, which is actually *exactly*
0.01000000000000000020816681711721685132943093776702880859375​

And thus the remainder is *exactly*
0.00999999999999999326927291320998847368173301219940185546875

which Rust shows as
0.009999999999999993

because that's enough to distinguish between the previous value

0.009999999999999992 which is sufficient to describe its exact value
0.0099999999999999915345494372331813792698085308074951171875

and the next value

0.009999999999999995 which is sufficient to describe its exact value
0.00999999999999999500399638918679556809365749359130859375​

https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=31bdb8bd3c3d446260d1850590271967

3 Likes

Sounds like time to read this:
"What Every Computer Scientist Should Know About Floating-Point Arithmetic": https://www.itu.dk/~sestoft/bachelor/IEEE754_article.pdf

2 Likes

I was going to comment with exactly this!

Floating point numbers are an excellent solution to the whole "represent an infinite range of numbers using a finite number of bits" problem, but it's a leaky abstraction and sometimes you need to understand how they are implemented to know where those edge cases are.

Thanks for your quick replies!

Not perfectly, but I think I understand a little more about floating point numbers.
Especially, I am glad this question has been answered.

fn main() {
    println!("{}", 0.1200 % 0.01);
}
Standard Output
0.009999999999999993

Once again, thank you for your cooperations!