Compiler acting (apparently) weird

If we consider the following code, it is according to me, the example of what might discourage a newcomer to Rust and dishearten a performance freak.
a) First the "let x=y" syntax has different semantics depending on the traits implemented. And these semantic differences have an influence on the syntactic availability of an object after an affectation. Not easy to master for students, especially when using external crates.
b) But what is stranger is the fact that the compiler does not optimize in the same way the handling of Foo and EmptyFoo. In fact, I read in another thread that the compiler always optimizes this kind of code in order to prevent the unnecessary physical bitcopy, but it is clearly not the case. And I really wonder why the compiler makes a bitcopy of p1 into p2 (which can be extremely costly if Foo is large, such as a structure with a large static array) ? Is there a way to avoid that ?

#[derive(Clone, Copy)]
pub struct FooCloneAndCopy {
    pub x: f64,
}

#[derive(Clone)]
pub struct FooCloneOnly {
    pub x: f64,
}

pub struct Foo {
    pub x:f64,
}

pub struct EmptyFoo {
}

fn main() {
    println!("FooCloneAndCopy");
    let p1 = FooCloneAndCopy { x: 0. };
    println!("p1 addr {:p}", &p1);
    let p2 = p1; // this is a copy and p1 remains available!
    println!("p1 addr:{:p} p2 addr{:p}", &p1, &p2);


    println!("FooCloneOnly");
    let p1 = FooCloneOnly { x: 0. };
    println!("p1 addr {:p}", &p1);
    let p2 = p1; // this is a move
    println!("p2 addr {:p}", &p2);

    println!("Foo");
    let p1 = Foo {x:0.};
    println!("p1 addr {:p}", &p1);
    let p2 = p1; // Same as above, and in release mode addresses remain different
    println!("p2 addr {:p}", &p2);

    println!("EmptyFoo");
    let p1 = EmptyFoo {  };
    println!("p1 addr {:p}", &p1);
    let p2 = p1; // Same as above, but in release mode the addresses are the same
    println!("p2 addr {:p}", &p2);
}

Isn't it? You can bitwise copy anything, and this invalidates the original instance if its ownership is non-trivial (i.e., it is not Copy). As simple as that.

First, the compiler isn't guaranteed to always optimize everything.

Second, the optimizations aren't about the case when you make two different objects! Since object identity may be significant, the compiler has no license to "optimize" two distinct objects into overlapping memory regions. (Note that this allows distinct empty objects to be put at identical addresses, because empty regions never overlap, even at the same addresses – their overlap is 0 bytes.)

1 Like

By taking the address of the values, you are forcing the compiler to put them on the stack which inhibits some optimization.

1 Like

Thanks for taking the time to answer my post, I do appreciate.

I do not agree that the semantic of "=" is simple in Rust. To take some examples, in Ocaml "let a=b" always mean that we have a syntactic equality (a is just a new name for b), in C, a=b means a is always a shallow bitcopy of b, and when copy is not possible (two arrays for example) the compiler says so. And a and b remain always accessible.
I do not mean that the Rust Way is inferior, I just say that it is not easy to grasp for beginners and that this complexity has to be justified by some benefits (which are real, but have to be demonstrated).

Regarding the second part of your answer, the "overlap argument" is clear.
But I would be interested in an example where object identity is significant ; this is not a malevolent question, I am really interested in finding a simple example where object identity is significant, because from my not-very-well-informed point of view, what's the use of copying an object which is lost, and not just rename it ?

Thanks again

It's the same in Rust.

In a compiler I wrote for a DSL, type descriptor structures were cached and keyed by their address in order to have fast (i.e., O(1)) lookup, even for large and/or infinite (recursive) types.

The reason non-Copy structs aren't usable after a move is explained in this section of the Book. Some immediate benefits apparent from that example is that potentially costly automatic clones are avoided, but you still have to go out of your way to leak memory. Bigger picture, the way moves work is part of Rust's overall ownership and borrowing model that ensures memory safety without inserting costs like garbage collection or implicitly boxing and wrapping everything in a Mutex, etc.

2 Likes

Thanks, I understand why non-Copy structs aren't usable after a move; my question is why bitcopy and not just rename because when I do "let a = b" and then invalidate b, why bitcopy b into a in the first place if nobody is able to access b anymore, and not just rename b as a ? I avoid the copy and I don't see any differences in semantics.
The answer of H2CO3 above is certainly valid, but not really simple, and if it is the only reason, then I am not sure it is worth the cost of potentially copying large structures.
So I am still waiting for a simple answer to the question. Just imagine I am a student and you are trying to explain to me how Rust is great, and I am bugging you with that question.

The optimizer will remove unnecessary bitcopies (provided it can prove they are unnecessary). However, bitcopies are typically necessary when a value is, for example, moved into or out of existing data structures that are not on the stack.

2 Likes

Locals receiving different identities when they don't need to can be a sign of the compiler being insufficiently smart, but in this case, I don't think it can do anything: println!() exposes the addresses of p1 and p2 as integers, and those addresses must remain valid for reading for as long as p1 and p2 are valid. So, e.g., if another thread is running, it could intercept the output of println!(), convert those integers back into *const Foo pointers, and read from those pointers until main() returns. And since they are two separate locals in reality, the compiler can't collapse them together, since that would be visible from the other thread.

In the EmptyFoo case, they're still two separate locals, but it doesn't matter, since an arbitrary number of zero-size structs can occupy any given memory address.

3 Likes

That's not so. I am a bit of a performance freak. I'm not a newcomer to Rust, which is probably the primary reason that my opinion differs, here.

The code in question is strictly disallowing the compiler from making certain optimizations. It does this by taking references and using them needlessly in println!(). This might actually be an example of the "observer effect" more than anything. The act of observing the reference disallows optimizing it out.

If you want to prove to yourself that let bindings are optimized out, measure it externally! Here's a simple example I came up with: Compiler Explorer

Five let bindings becomes 2 instructions.

5 Likes

This is somewhat tangential, but this blog series walks through porting a C program to Rust without losing performance. It focuses a lot on safe vs. unsafe, but there's another reoccurring theme: you can generally just leave in things like unused initializations because the optimizer has thought about that too, and will take care of it for you.

Dead store elimination is the sort of thing modern compilers can do while wearing a blindfold and riding a unicycle.

5 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.