Whether "move" operation on a stack value is cheap?

The short answer is it depends, and it depends mainly on the compiler optimizations:

  • machine code wise, there is, at first, no difference between a Copy and a non-Copy value. Indeed, the true meaning of Copy is: "the compiler won't invalidate / prevent you from using a "moved" value". And "moving" a value is, by default, achieved by bitwise-copying the value (memmove), in all cases (in the !Copy case, the compiler will just forbid you from using the initial data / memory, which has become stale).

  • Now, in practice, this is actually very hard to predict, since both Rust and its compiler backend (LLVM) will obviously try to optimize "unnecessary bit-copies".

A good example of the latter, and completely on point for this thread, is the following quote:

Indeed, in that example we can see that a Vec, which is three-pointers wide, is considered wide enough for it to be "passed by reference with indirection" even when moved (I think this can happen thanks to the undefined (extern ")Rust(") ABI for functions).

So, ownership is used to express who drops a value, and Copy to express wether a moved-out-of value gets invalidated / becomes unusable or not.

Anything machine-related, on the other hand (e.g., whether to perform a bitwise copy or to use indirection), is left for the compiler to decide, and is thus quite hard to predict.


In practice,

on the other hand, there are some cases that guarantee that a bitwise copy cannot be happening: when using types that do bundle indirection within them, such as:

  • indeed, a Boxed type,

  • ref-counted references types (Arc (or its single-threaded optimization, Rc)),

  • or when using compile-time-checked short-lived borrows (shared, &, or exclusive, &mut).

But, imho, if an element is big enough for implicit compiler-inserted bitwise copies to be an issue (even when moving a value around), then it is not for the user of the type to start using, for instance, a Box around it, but rather the one defining the type to bundle an internal Box-like indirection to improve that.

One good example of that is when dealing with enums, and when there is one variant that may be quite bigger than then others. In that case, Box-ing that variant will mitigate the cost of moving around such an enum, even in the case of other variants being used.

  • One very frequent case of that are Error types: those get bundled as the Err branch within the Result enum, which is one branch which is not expected to be hit that much, in theory (failure path).

    And yet, if the Error type is big, every time a function returning a Result with that Error type succeeds, the compiler may be emitting a big bitwise copy since the size of the enum itself needs to be able to hold the Error variant.

    So, in that case, a good strategy is to Box the data / payload / info bundled within the Error variant, so as to have an upper-bound on the size of that variant (1-pointer-wide in the case of a Box-ed fixed type, and two in the case of a "fat pointer", that is, a pointer to a !Sized type such as dyn Error).

    • Since 2-pointer-wide is already quite big for some small return types in the Ok case, Box<dyn Error> is already a bit suboptimal, despite it being used in many places. In order to palliate that, one can double-box Box<Box<dyn Error>>, so as to make sure the Error variant is not more than 1-pointer-wide, or use special layout-optimized type-erased Error types, such as ::anyhow's.
10 Likes