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

In the "ownership" chapter of the book, there is the following paragraph:

In addition, there’s a design choice that’s implied by this: Rust will never automatically create “deep” copies of your data. Therefore, any automatic copying can be assumed to be inexpensive in terms of runtime performance.

My understanding is that when you move a (non-Copy) value on the stack to another function, the compiler will actually transfer some kind of reference to the callee, so it doesn't create "deep copy" and is inexpensive.

However, in chapter "Using Box to Point to Data" in the book, there is this sentence:

Boxes don’t have performance overhead, other than storing their data on the heap instead of on the stack. But they don’t have many extra capabilities either. You’ll use them most often in these situations:

  • When you have a large amount of data and you want to transfer ownership but ensure the data won’t be copied when you do so

So it implies that when transferring ownership of values on the stack, deep copy will happen.

Which is exactly true? Thanks.

A Box consists of two parts:

  1. A pointer to a heap allocation.
  2. The heap allocation with the value inside.

When a Box is moved, only the pointer is physically copied and the pointer is very small. The move does not touch the heap allocation, and as such, moving a Box can be used to cheaply transfer ownership of a large amount of data, without physically moving it around in RAM.

2 Likes

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.
9 Likes

It copies the entire value into the new function’s stack frame and marks the old lcation as invalid. This is a “shallow” copy because it doesn’t follow any pointers or references, but it can still be a significant amount of data for large structures (whatever mem::size_of reports).

Box has a small stack size (1 pointer) which makes ownership transfers more efficient, but every access of the contained value has a small extra cost (a pointer dereference).

1 Like

That's not entirely true. If you have a non-move closure that uses a capture by value, that value will be moved once into the closure state and then again where it is used, and the closure will be FnOnce. But if that value has a Copy type, it will be captured by reference instead, only copied at the use within the closure, and the closure may be Fn/FnMut.

2 Likes

Yeah, ok, my statement has that "asterisk" of holding modulo "specialization" changing other Rust semantics (closures being a good example, specialization (whether on nightly, or via auto-ref) being another), since in that case, we haven't actually just added an impl Copy, we have changed other code at a distance.

This isn't really true, non-Copy data is passed by reference, without making an additional copy. And locations are only marked as invalid (by setting their drop flag) when it isn't known at compile time whether a value was previously moved out of.

I perhaps wasn’t clear; I never meant to imply that the marking was necessarily manifested at runtime as a drop flag, but rather conceptually: The compiler ensures that the region of memory that previously contained the moved value is treated as unused after the move, without taking any particular action to clear it.

As for passing by-ref or by-copy, I’ll defer to the people who actually know what they’re talking about. I know the language semantics much better than compiler implementation details.

1 Like

Ah indeed, I made a conceptual mistake of "deep copy" here

Quite informative! :+1:

1 Like