Rust Performance Engineering

Move Semantics and Performance

In Rust, moving a value (let b = a; or passing a by value into a function) is always a shallow bitwise copy (memcpy) of the value's stack representation, statically invalidating the source variable so its destructor (Drop) does not run twice. Moving a Vec<T>, String, or Box<T> never copies the underlying heap buffer—it copies only the 24-byte (or 8-byte) stack header!

Moving a 100 MB `Vec<u8>` (`let dst = src;`):

  Stack (24 Bytes Copied):                    Heap (100 MB Untouched!):
  `src` (Invalidated)                         +-----------------------------------+
  `dst` [ ptr | len: 100M | cap: 100M ] ----->| 100,000,000 bytes on the heap     |
                                              +-----------------------------------+
  Cost of move: ~1-2 CPU cycles (3 register moves), regardless of heap size!

Comparing Move, Clone, and Borrow Costs

Operation Stack Cost Heap Cost Source Variable State After
Borrow (&T / &mut T) Copies 8-byte pointer (or 16-byte fat pointer) 0 bytes Remains valid; borrowed for 'a.
Move (T) Copies size_of::<T>() bytes (24B for Vec/String) 0 bytes (Heap pointers transferred as-is!) Statically invalidated by compiler.
Clone (t.clone()) Copies stack header Allocates new heap buffer & deep-copies all elements Remains valid and independent.

Return Value Optimization (RVO / NRVO) on Large Structs

What happens when a function constructs and returns a larger struct by value (fn build_table() -> [u64; 128])? At the ABI level, the caller reserves space for the return value in its own stack frame and passes a hidden out-pointer (sret) to the callee. The callee constructs the [u64; 128] array directly inside the caller's stack slot, resulting in zero byte copies on return!

use std::mem::size_of;

pub struct PayloadBuffer {
    pub tag: u64,
    pub data: Vec<u8>,
}

/// Transfers ownership of `PayloadBuffer` by value.
/// Even though `buf.data` holds 1,000,000 bytes on the heap, moving `PayloadBuffer`
/// copies at most `size_of::<PayloadBuffer>()` (32 bytes) on the stack!
pub fn attach_checksum(mut buf: PayloadBuffer) -> PayloadBuffer {
    let heap_addr_before = buf.data.as_ptr().addr();
    buf.tag = buf.data.len() as u64;
    let heap_addr_after = buf.data.as_ptr().addr();

    assert_eq!(heap_addr_before, heap_addr_after);
    buf
}

fn main() {
    println!("Stack size of PayloadBuffer: {} bytes", size_of::<PayloadBuffer>());

    let initial = PayloadBuffer {
        tag: 0,
        data: vec![0x42; 1_000_000],
    };
    let original_heap_addr = initial.data.as_ptr().addr();

    // Move `initial` into `attach_checksum` and move the result into `finalized`:
    let finalized = attach_checksum(initial);

    // The 1 MB heap buffer never moved or reallocated!
    assert_eq!(finalized.data.as_ptr().addr(), original_heap_addr);
    println!(
        "Moved 1 MB Vec across function return with 0 heap copies (tag={})",
        finalized.tag
    );
}
Stack size of PayloadBuffer: 32 bytes
Moved 1 MB Vec across function return with 0 heap copies (tag=1000000)

Taking Ownership via std::mem::take and std::mem::replace

When you only have a mutable reference &mut T (such as inside a state machine &mut self method) and want to move a heap-allocated Vec or String out of a field without cloning it, use std::mem::take(&mut self.buffer). It swaps self.buffer with Default::default() (an empty Vec::new() or String::new(), which performs zero heap allocation!) and returns the owned buffer in $O(1)$ time:

#[derive(Default)]
pub struct BatchAccumulator {
    pending: Vec<u64>,
}

impl BatchAccumulator {
    pub fn push(&mut self, item: u64) {
        self.pending.push(item);
    }

    /// Moves the accumulated `Vec<u64>` out in O(1) time without cloning!
    pub fn flush(&mut self) -> Vec<u64> {
        std::mem::take(&mut self.pending)
    }
}

fn main() {
    let mut acc = BatchAccumulator::default();
    acc.push(10);
    acc.push(20);

    let batch = acc.flush();
    println!("Flushed batch: {batch:?}, remaining pending: {}", acc.pending.len());
}
Flushed batch: [10, 20], remaining pending: 0