C++ vs Rust ( This is not a fight area :) )

Hello,

I tried to compare simple C++ and Rust fonction:
In C++, a simple function like:

int add(int a, int b) {
    return (a*b)/b;
}

Gives the following asm:

add(int, int):
        mov     eax, edi
        ret

I now that the C++ compiler used UB to optimize le following which is ok here.

In Rust, the same code:

pub fn square(num: i32, fac: i32) -> i32 {
    (num*fac)/fac
}

Gives the following asm:

square:
        push    rax
        test    esi, esi
        je      .LBB0_3
        imul    edi, esi
        mov     eax, esi
        not     eax
        lea     ecx, [rdi - 2147483648]
        or      ecx, eax
        je      .LBB0_2
        mov     eax, edi
        cdq
        idiv    esi
        pop     rcx
        ret

Is there a way to ask rustc to ignore overflow and div by 0 because we now we will never have it or we don't care in our program? like a game.

Thank you,

1 Like

I don't think there is a flag for rustc, but you could use unchecked division

Besides using unchecked operations, you could try making the denominator a NonZeroI32. The standard library knows that dividing by one of those does not have to handle division by zero.

2 Likes

I'm pretty sure num.unchecked_mul(fac).checked_div(fac).unwrap_unchecked() compiles down to a single mov like C++. But you really need extra care that it is never invalid.

Edit: sorry wrong reply :frowning:

Wrong reply, but you are on the right path. This compiles to mov:

fn add(a: i32, b: i32) -> i32 {
    if b == 0 {
        unsafe { unreachable_unchecked(); }
    }
    (unsafe { (i64::from(a).unchecked_mul(i64::from(b))).wrapping_div(i64::from(b)) }) as i32
}

Rust is ready to compile-away the overflow cases if you would prove that there are no overflow.

Which is a good thing: in C++ it's trivial to get incorrect yet fast code, in Rust it's the opposite. I know what I, for one, want 99.999% of time.

This works as well:

pub fn square(num: i32, fac: i32) -> i32 {
    if fac == 0 {
        return num;
    }
    let num = num as i64;
    let fac = fac as i64;
    ((num * fac) / fac) as i32
}
4 Likes

How do you know there is no overflow? If you know the numbers are small, then this works:

use std::hint::assert_unchecked;

pub fn f(num: i32, fac: i32) -> i32 {
    unsafe {
        assert_unchecked(num > -10000 && num < 10000);
        assert_unchecked(fac > 0 && fac < 10000);
    }
    (num*fac)/fac
}
4 Likes

I think the proper solution for the division in Rust would be to use NonZero and in release mode overflow checks are disabled by default (wrapping arithmetic is used). I'm on my phone, so I'm not going to write it out.

1 Like

This doesn't work because wrapping arithmetic doesn't have the property we want for optimization here (a * b / b == a).

There isn't, and more generally there will never be a flag to rustc that adds UB to safe code (the way -ffast-math makes otherwise-safe things UB in C).

You always have to change the code if you want to introduce UB.

Well, this compiles to just a mov, for example:

use std::num::NonZero;
#[unsafe(no_mangle)]
pub fn square(num: i32, fac: NonZero<i32>) -> i64 {
    (num as i64 * fac.get() as i64)/(fac.get() as i64)
}

https://rust.godbolt.org/z/Wcb8Eqs4h

Based on how prevalent crashes are in games, I never trust people that say they "know" something won't happen in a game.

9 Likes

Or in other words, UB enables additional optimizations compared to the fixed "panic in debug, overflow in release" logic that Rust has by default. At the same time, UB is much too strong because it can propagate beyond the function boundary (which OP may not realize).

It would be sufficient if the compiler could choose the result of overflow and division by zero arbitrarily in each case. But how many real-world optimizations would that enable?

That optimisation is incorrect then. You need to do the arithmetic in i64 for the intermediate results for it to be correct (as scottmcm pointed out).

i32 is sufficient if you know that all the intermediate results fit in i32 which was the assumption in the original question.

Thank you for all your answers.

I’m keeping two of them:

fn add(a: i32, b: i32) -> i32 {
    if b == 0 {
        unsafe { unreachable_unchecked(); }
    }
    (unsafe { (i64::from(a).unchecked_mul(i64::from(b))).wrapping_div(i64::from(b)) }) as i32
}

And

pub fn f(num: i32, fac: i32) -> i32 {
    unsafe {
        assert_unchecked(num > -10000 && num < 10000);
        assert_unchecked(fac > 0 && fac < 10000);
    }
    (num*fac)/fac
}

Both produce exactly the same assembly.

This answer :

use std::num::NonZero;
#[unsafe(no_mangle)]
pub fn square(num: i32, fac: NonZero<i32>) -> i64 {
    (num as i64 * fac.get() as i64)/(fac.get() as i64)
}

produces assembly that uses a 64-bit register, which is not the behavior I want.

I accept the following version:

pub fn f(num: i32, fac: i32) -> i32 {
    unsafe {
        assert_unchecked(num > -10000 && num < 10000);
        assert_unchecked(fac > 0 && fac < 10000);
    }
    (num*fac)/fac
}

because its bound checks are more explicit than the i64::from trick, which makes readers think we are actually using 64-bit registers.

1 Like

Note that in the last version there are no bound checks. There are unchecked assumptions.

Yes, sorry, I used the wrong terms.

That function returns an i64. If you have it return an i32 instead, it produces the right assembly.

1 Like

Yeah. All that's needed is to wrap the current function body in (...) as i32. I checked on the compiler explorer.