Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 052 / 100

Memory layout and performance: best practices explained

Write fast Rust.

Expona Admin7 min read

Performance in Rust isn't just about algorithm design—it's about understanding how values move through memory and organizing your code to minimize allocations and copies. We'll walk through the concrete practices that separate a fast Rust application from one that leaves performance on the table.

Stack vs heap — Prefer stack values and avoid unnecessary boxing

The stack is your friend. Stack allocation is nearly free: a pointer bump and a deallocate on scope exit. Heap allocation requires a system call, bookkeeping, and potential fragmentation. When we design data structures, the first question should be: does this need to live on the heap?

Consider a typical domain model in a web service. Early in a project, we might reach for Box<T> everywhere because it feels flexible:

rust
// Unnecessary boxing
struct User {
    id: u64,
    name: Box<String>,
    email: Box<String>,
    metadata: Box<HashMap<String, String>>,
}

fn create_user(id: u64, name: String, email: String) -> Box<User> {
    Box::new(User {
        id,
        name: Box::new(name),
        email: Box::new(email),
        metadata: Box::new(HashMap::new()),
    })
}

This works, but every field access now requires dereferencing. In a request handler that processes thousands of users per second, those dereferences add up. The heap pressure is also visible in flamegraph profiling as allocator contention.

The better approach keeps small types stack-resident and only boxes when there's genuine size uncertainty or trait objects are needed:

rust
// Stack-first design
struct User {
    id: u64,
    name: String,      // Stack-allocated; String owns its data
    email: String,
    metadata: HashMap<String, String>,  // Inline; data on heap via String/HashMap internals
}

fn create_user(id: u64, name: String, email: String) -> User {
    User {
        id,
        name,
        email,
        metadata: HashMap::new(),
    }
}

This struct is fixed-size on the stack. The String and HashMap manage their own heap buffers, but the struct itself and small strings fit inline, avoiding extra indirection.

Trait objects are the valid exception. If you genuinely need polymorphism—say, a vector of different payment processors—boxing is appropriate:

rust
trait PaymentProcessor {
    fn charge(&self, amount: u64) -> Result<(), String>;
}

struct PaymentService {
    processors: Vec<Box<dyn PaymentProcessor>>,
}

But for concrete types with known fields, keep them on the stack whenever feasible.

Avoid clones — Borrow instead of cloning in hot paths

Cloning is the slowest correct solution. When we're under deadline and ownership rules feel restrictive, it's tempting to write .clone() and move on. In production systems handling thousands of requests per second, each avoidable clone compounds into measurable latency.

Here's a common scenario: a request handler that needs to pass data to multiple processing functions:

rust
// Cloning everywhere
fn handle_order(order: Order) {
    validate_order(order.clone());
    calculate_tax(order.clone());
    apply_discounts(order.clone());
    persist_order(order);
}

If Order contains nested collections, each clone traverses the entire tree. Under load, this becomes a GC-like pause generator.

The fix: borrow references. Functions that don't need ownership should take &self:

rust
// Borrowing
fn handle_order(order: Order) {
    validate_order(&order);
    calculate_tax(&order);
    apply_discounts(&order);
    persist_order(order);  // Only move when ownership transfer is needed
}

fn validate_order(order: &Order) {
    // inspect order, don't modify
}

fn calculate_tax(order: &Order) -> f64 {
    // read-only access
    0.0
}

fn apply_discounts(order: &Order) -> f64 {
    // read-only access
    0.0
}

fn persist_order(order: Order) {
    // we own order here; can move it into storage
}

This is zero-cost. A reference is just a pointer; passing it around doesn't copy the data. In hot loops or request handlers, this distinction shows up immediately in profile data.

When modification is necessary, use mutable references selectively:

rust
fn apply_discounts(order: &mut Order, discount: f64) {
    order.total -= (order.total as f64 * discount) as u64;
}

fn main() {
    let mut order = Order { total: 1000 };
    apply_discounts(&mut order, 0.1);
}

The compiler ensures only one mutable reference exists at a time, preventing data races without runtime overhead.

Preallocate — Vec::with_capacity for known sizes

Vectors grow exponentially to amortize allocation cost, but if we know the final size, why pay for growth at all?

A typical scenario in data processing: reading a batch of records and transforming them:

rust
// Allocates multiple times as vec grows
fn process_batch(records: Vec<RawRecord>) -> Vec<ProcessedRecord> {
    let mut results = Vec::new();
    for record in records {
        results.push(transform(record));
    }
    results
}

If we're processing 100,000 records, the vector might allocate at sizes 1, 2, 4, 8, 16, 32, 64K. Each reallocation copies the old buffer to a new one—expensive.

With preallocation, one shot:

rust
fn process_batch(records: Vec<RawRecord>) -> Vec<ProcessedRecord> {
    let mut results = Vec::with_capacity(records.len());
    for record in records {
        results.push(transform(record));
    }
    results
}

The memory is reserved upfront. Push operations only write to the next slot—no reallocation, no copying. In scenarios processing millions of items, this is the difference between microseconds and milliseconds.

For fixed-size collections, consider arrays:

rust
// Fixed 10-element buffer on the stack
let mut scores: [i32; 10] = [0; 10];
scores[0] = 100;

Arrays are stack-allocated and have zero overhead. Use them when size is truly static.

Profile — cargo flamegraph to find hotspots

Guessing where the performance problem is leads to chasing ghosts. Profiling tools show us reality.

flamegraph is the standard visualization. Install it:

rust
cargo install flamegraph

Run a benchmark or a representative production-like workload:

rust
cargo flamegraph --bin myapp -- arg1 arg2

This generates a flamegraph.svg showing which functions consume CPU time. Tall stacks indicate deep call chains or expensive work. Wide blocks indicate functions that run a long time.

In one e-commerce project, we assumed JSON parsing was the bottleneck. The flamegraph revealed the real culprit: a utility function cloning order IDs for logging, called inside a loop across millions of orders. One refactor—using borrowed string slices—reduced P99 latency by 40ms.

For memory profiling, use valgrind or heaptrack on Linux, or Instruments on macOS:

rust
valgrind --tool=massif ./target/release/myapp

This shows allocation patterns over time, heap fragmentation, and peak memory use. Combined with flamegraph, you get a complete picture.

In development, also use #[inline] judiciously. The compiler often makes good decisions, but in hot loops, helping it out matters:

rust
#[inline]
fn is_valid_email(email: &str) -> bool {
    email.contains('@')
}

This small function is a candidate for inlining. Without the hint, the compiler might not inline it if called from multiple places. In tight loops processing millions of items, avoiding function call overhead stacks up.

Common mistakes

  • Boxing all structs thinking it's more "flexible." Boxing adds indirection without benefit unless size is genuinely unknown or you need trait objects. Stack allocation is faster and uses less memory.
  • Cloning in loops because "it's simpler." A clone inside a loop that runs 1000 times is 1000× the work. Borrowing takes a few moments to learn but pays dividends in production.
  • Creating vectors without capacity. Small vectors might realloc multiple times. Always use with_capacity if you know or can estimate the final size.
  • Not profiling before optimizing. We've all optimized the wrong function. Profile first. Let data guide you.
  • Assuming Rust's defaults are slow. Rust is designed for performance. The defaults—stack allocation, move semantics, zero-cost abstractions—are your baseline. Deviations (boxing, cloning, trait objects) are the outliers, and they should be intentional.

Key takeaways

  • Keep types on the stack by default. Box only for trait objects or when size is genuinely unknown.
  • Borrow references in hot paths instead of cloning. Passing &T is zero-cost and often makes function intent clearer.
  • Preallocate vectors with with_capacity when you know the final size. One allocation beats repeated reallocations.
  • Use cargo flamegraph and profiling tools to identify actual bottlenecks rather than optimizing by intuition.
  • Measure before and after changes. A 10% improvement in an irrelevant function is wasted effort; a 50% improvement in a hot path is visible to users.
  • Remember that memory layout and performance are interconnected. Understanding how values live in memory—stack vs. heap, inline vs. indirected—is fundamental to writing fast Rust.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles