You are experiencing the observer effect: by taking a pointer to these fields (which happens when you format a reference with {:p}
) you have caused both the compiler and the optimizer to alter their behavior. You changed the outcome by measuring it!
Taking a pointer to something requires that it be in addressable memory somewhere, which means the compiler couldn't put b
or p
in CPU registers (where it prefers to put stuff when possible, because registers are fast). We haven't even gotten to the optimization stage but we've already affected decisions the compiler has made about where the data needs to be -- that's a big deal that limits what the optimizer can do.
Now the optimizer has to figure out whether the move can be elided. Taking pointers to b
and p
could prevent the optimizer from doing so, but it may not. It's also possible that you're just compiling without optimizations.
Note that even if Point
were Copy
, if you removed all of the pointer printing, the optimizer may even elide the copy if it can prove that p
is either unused on the other side of the copy, or neither value is mutated (which is a pretty good bet since neither are declared mut
).
Here's the rule: don't ever try to determine what the compiler or optimizer does with your code from within that code -- doing so may actually subvert the optimizer and lead you to a wrong conclusion. This applies to every language, not just Rust.
The only valid approach is to look at the generated assembly.
So let's do that!
I used your code as a starting point and wrote two different functions, one with the move and one without:
#![feature(bench_black_box)]
struct Point {
pub x: f64,
pub y: f64,
pub z: f64,
}
#[inline(never)]
fn a() {
let p = Point {
x: 1.0,
y: 2.0,
z: 3.0,
};
std::hint::black_box(p);
}
#[inline(never)]
fn b() {
let p = Point {
x: 1.0,
y: 2.0,
z: 3.0,
};
let b = p;
std::hint::black_box(b);
}
fn main() {
a();
b();
}
A few things to point out before we move on to look at the assembly:
std::hint::black_box()
is an experimental function whose purpose is to act as a, well, black box to the optimizer. The optimizer is not allowed to look into this function to see what it does, therefore it cannot optimize it away. Without this, the optimizer would look at the body of the function and correctly conclude that it doesn't do anything at all, and eliminate the whole thing as a no-op.
- We mark the two functions as
#[inline(never)]
to ensure that the optimizer won't inline both functions into main()
. This makes them easier to compare to each other.
So we should get two functions out of this and we can compare their assembly.
But we don't get two functions.
In the generated assembly, b()
is nowhere to be found. So what happened instead? Let's look to see what main()
does:
pushq %rax
callq playground::a
popq %rax
jmp playground::a
Well... would you look at that. The optimizer figured out that two functions are semantically equivalent, despite one of them having an additional move. So it decided to completely eliminate b()
and make it an alias for a()
, resulting in two calls to a()
!
Out of curiosity, I changed the literal f64
values in b()
to prevent the functions from being unified and saw what I expected to see: other than the different values, the emitted assembly was identical. The compiler elided the move.
(Playground -- note that you need to manually press the three-dots button next to "run" and select the "ASM" option.)