1

The code below gives different result depending on environments:

fn main() {
    let n: usize = 5489031744;
    let n_cbrt: usize = (n as f64).cbrt().floor() as usize;
    println!("{}", n_cbrt);
}

Rust Playground and my Linux server (Ryzen) gives

1763

though M1 Macbook Air gives

1764

Mathematically, 1764.pow(3) exactly equals 5489031744 so the latter result is correct. But, anyway, the problem is the inconsistency rather than mathematical correctness.

Is this inconsistency a bug? If not, does this mean we cannot write a cross-platform application whose behavior is fully predictable and consistent in Rust?

By the way, the C++ program below gives 1764 in both my Linux server (Ryzen) and M1 Macbook Air (and in an online compiler).

#include <iostream>
#include <cmath>

int main() {
    std::cout << cbrt(5489031744) << "\n";
}

Very strange as it seems Rust's cbrt() calls C++'s cbrt() directly (source):

pub fn cbrt(self) -> f64 {
    unsafe { cmath::cbrt(self) }
}

Summary:

Environment Rust C++
Linux (Ryzen) 1763 1764 (clang++, g++)
macOS (M1) 1764 1764 (clang++, g++)
Rust Playground 1763
C++ online compiler 1764 (g++)
FreeBSD (Intel) 1764 1764 (clang++, g++)
ynn
  • 3,386
  • 2
  • 19
  • 42
  • What compiler did you use for the C++ program? – Jmb Nov 09 '22 at 07:31
  • Adding to the confusion is the fact that using `f32` instead of `f64` in playground gives the correct answer. Not sure if it helps (can't think of any reason), but probably someone will be able to make sense of it. – Cerberus Nov 09 '22 at 07:32
  • @Jmb `g++-12 (Homebrew GCC 12.2.0) 12.2.0` for M1 mac and `g++ (GCC) 12.2.0` for Linux. – ynn Nov 09 '22 at 07:33
  • Are the architectures the same? Or is your server only 32-bit? If so, 5489031744 would overflow a `usize`. – isaactfa Nov 09 '22 at 07:34
  • @isaactfa Both 64bit. M1 mac is arm64, and my Linux server is 64bit, Ryzen, Arch Linux. – ynn Nov 09 '22 at 07:37
  • Note that Rust uses LLVM as a backend. It would be interesting to see what `clang++` gives for the C++ code on Linux. – Jmb Nov 09 '22 at 07:37
  • @Jmb I just tried `clang++` both in M1 mac and Linux, both gave `1764`. – ynn Nov 09 '22 at 07:41
  • 1
    Why do you use `floor` instead of rounding to the nearest integer? Floating-point computations are generally inexact, therefore the result of `cbrt` will be something as 1764 + _e_, where _e_ is some very small number. In case it is positive, `floor` will produce 1764. However, if _e_ is negative, `floor` will produce 1763. – Daniel Langr Nov 09 '22 at 08:05
  • @DanielLangr This is just a minimal working example. The point is this: Why inconsistent though [Rust's `f64` is guaranteed to comply with IEEE 754-2008](https://doc.rust-lang.org/std/primitive.f64.html)? – ynn Nov 09 '22 at 08:10
  • 1
    @ynn Interesting article: https://randomascii.wordpress.com/2013/07/16/floating-point-determinism/. Quoting: _"the IEEE standard does not guarantee that the same program will deliver identical results on all conforming systems"_. Possibly relevant SO answer: https://stackoverflow.com/a/41001110/580083 (the question is about C/C++, but it may apply for Rust as well). – Daniel Langr Nov 09 '22 at 08:35
  • @DanielLangr Was extremely helpful. Thank you. Quote: *The IEEE standard does guarantee some things. It guarantees more than the floating-point-math-is-mystical crowd realizes, but less than some programmers might think. (snip) Some of the things that are guaranteed are the results of addition, subtraction, multiplication, division, and square root.* – ynn Nov 09 '22 at 09:40

2 Answers2

1

It is a floating point issue, calculation using floating point are not 100% percise, and so some inconsistency arise resulting in either x.00000000001 or x.99999999999.

In your case the issue was probably the following:

(n as f64).cbrt() resulted in 1763.999999999999999999999999999999

but adding .floor() exacerbated the problem, a quick fix would be to use .round() instead

If you require .floor() for some specific logic, you can use a decimal library such as https://docs.rs/rust_decimal/latest/rust_decimal/struct.Decimal.html which will probably hurt performance

Another hack than can be used if you use .floor() right after it is to add an epsilon (0.00000000001).

fn main() {
   let n: usize = 5489031744;
   let n_cbrt: usize = ((n as f64).cbrt() + 0.0000000001).floor() as usize;
   println!("{}", n_cbrt);
}
Oussama Gammoudi
  • 685
  • 7
  • 13
  • 1
    It is guaranteed Rust's `f64` complies with IEEE 754-2008 ([source](https://doc.rust-lang.org/std/primitive.f64.html)), so I thought (1) the calculation result is not necessarily mathematically correct by nature, BUT (2) the result, if mathematically incorrect, should be consistent independent of environments. Is my understanding wrong? – ynn Nov 09 '22 at 08:08
  • @ynn I'm not an expert on the subject, but the language itself can't guarantee anything because it uses llvm which outputs cpu opcodes that are dependent on the cpu implementation. so the issue can happen at any step. any additional guarantees that the language would make would hurt performance a lot. IEEE 754-2008 is twice as precise as f32 but still not 100% percise. – Oussama Gammoudi Nov 09 '22 at 08:15
1

It looks like this is a difference between gcc and clang (/ libm): https://godbolt.org/z/oEPfaYP5W

Converting your program to C with a large-ish amount of precision:

#include <stdio.h>
#include <math.h>

int main() {
    printf("%.15f", cbrt(5489031744));
}

Compiling with GCC:

1764.000000000000000

with clang:

1763.999999999999773

I would assume it's a factor of cbrt being implemented slightly differently between the two libc/libm(ath), which leads to very slightly different results (by less than 1.3e-16).

But because you then proceed to floor() the result rather than just round it, this slight difference leads to a massive divergence in the final result, because you massively expand slight instabilities in the floating-point computation.

edit: looking at the assemblies, it seems like GCC just compiles the cube root at compile time:

.LC1:
        .string "%.15f"
main:
        sub     rsp, 8
        movsd   xmm0, QWORD PTR .LC0[rip]
        mov     edi, OFFSET FLAT:.LC1
        mov     eax, 1
        call    printf
        mov     eax, 0
        add     rsp, 8
        ret
.LC0:
        .long   0
        .long   1083936768

while clang actually calls the CBRT intrinsic:

.LCPI0_0:
        .quad   0x41f472bfa4000000              # double 5489031744
main:                                   # @main
        push    rax
        movsd   xmm0, qword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero
        call    cbrt@PLT
        lea     rdi, [rip + .L.str]
        mov     al, 1
        call    printf@PLT
        xor     eax, eax
        pop     rcx
        ret
.L.str:
        .asciz  "%.15f"

Looking at other compilers, icc returns 1764.000000000000000 while tcc -lm returns 1763.999999999999773

Masklinn
  • 34,759
  • 3
  • 38
  • 57