Skip to content

Chapter 24

Performance & Profiling

Measure before you optimize. This chapter is about finding the slow parts with benchmarks and profilers, then applying targeted optimizations - zero-copy parsing, buffer reuse, and allocation reduction.

Benchmarking with Criterion

criterion is the standard benchmarking library for Rust. It runs your code thousands of times, measures the distribution of timings, detects statistical significance, and warns you about regressions between runs.

cargo add --dev criterion --features html_reports

# Add to Cargo.toml:
# [[bench]]
# name = "parsing"
# harness = false
Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "parsing"
harness = false    # Use Criterion's own main, not the built-in test harness

Write benchmarks in benches/parsing.rs:

benches/parsing.rs
use criterion::{criterion_group, criterion_main, Criterion, black_box};

use webserver::request::Request;

const SIMPLE_REQUEST: &str = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";

const COMPLEX_REQUEST: &str = "\
POST /api/todos HTTP/1.1\r\n\
Host: localhost:8080\r\n\
Content-Type: application/json\r\n\
Authorization: Bearer abc123\r\n\
Accept: application/json\r\n\
Accept-Encoding: gzip, deflate\r\n\
User-Agent: benchmark/1.0\r\n\
Content-Length: 27\r\n\
\r\n\
{\"title\":\"Benchmark this\"}";

fn bench_parsing(c: &mut Criterion) {
    c.bench_function("parse simple GET", |b| {
        b.iter(|| {
            let req = Request::parse(black_box(SIMPLE_REQUEST)).unwrap();
            black_box(req);
        });
    });

    c.bench_function("parse complex POST", |b| {
        b.iter(|| {
            let req = Request::parse(black_box(COMPLEX_REQUEST)).unwrap();
            black_box(req);
        });
    });
}

fn bench_routing(c: &mut Criterion) {
    let router = webserver::handler::build_router();
    let req = Request::parse(SIMPLE_REQUEST).unwrap();

    c.bench_function("route lookup", |b| {
        b.iter(|| {
            let resp = router.route(black_box(&req));
            black_box(resp);
        });
    });
}

criterion_group!(benches, bench_parsing, bench_routing);
criterion_main!(benches);

Run the benchmarks:

cargo bench

# Output:
# parse simple GET     time:   [245.3 ns 247.1 ns 249.2 ns]
# parse complex POST   time:   [1.023 µs 1.031 µs 1.040 µs]
# route lookup         time:   [42.31 ns 42.78 ns 43.29 ns]

black_box prevents the compiler from optimizing away the code under test. Without it, the compiler might notice the result is unused and skip the work entirely.

On subsequent runs, Criterion compares against previous results and tells you if performance changed:

parse simple GET     time:   [232.1 ns 233.8 ns 235.6 ns]
                     change: [-5.8% -5.3% -4.9%] (p = 0.00 < 0.05)
                     Performance has improved.
Tip
Criterion generates HTML reports in target/criterion/ with charts showing timing distributions, violin plots, and regression analysis. Open target/criterion/report/index.html in your browser.

Benchmark Groups for Comparison

fn bench_parse_strategies(c: &mut Criterion) {
    let mut group = c.benchmark_group("request_parsing");

    let sizes = [
        ("tiny", "GET / HTTP/1.1\r\n\r\n"),
        ("small", SIMPLE_REQUEST),
        ("large", COMPLEX_REQUEST),
    ];

    for (name, input) in &sizes {
        group.bench_with_input(*name, *input, |b, raw| {
            b.iter(|| Request::parse(black_box(raw)).unwrap());
        });
    }

    group.finish();
}

Profiling with Flamegraphs

Benchmarks tell you how fast something is. Profilers tell you where the time is spent. A flamegraph visualizes your program's call stack - the wider a bar, the more CPU time that function consumed.

# Install cargo-flamegraph
cargo install flamegraph

# Profile your server under load
# Terminal 1: start the server
cargo build --release
./target/release/webserver

# Terminal 2: generate load
wrk -t4 -c100 -d10s http://127.0.0.1:8080/

# Terminal 3: capture the flamegraph
# (on macOS, use Instruments or dtrace instead of perf)
cargo flamegraph --bin webserver -- --port 8080

This produces a flamegraph.svg file. Open it in a browser - it's interactive (click to zoom):

┌─────────────────────── main ───────────────────────────┐
│  ┌──── handle_connection ──────────────────────┐      │
│  │  ┌── process_request ─────────────┐         │      │
│  │  │  ┌─ Request::parse ──────┐     │         │      │
│  │  │  │  split     filter_map │     │         │      │
│  │  │  │  ████████  ████████   │     │         │      │
│  │  │  └───────────────────────┘     │         │      │
│  │  │  ┌─ Router::route ─┐          │         │      │
│  │  │  │  HashMap::get   │          │         │      │
│  │  │  │  ████           │          │         │      │
│  │  │  └─────────────────┘          │         │      │
│  │  └────────────────────────────────┘         │      │
│  │  ┌── write_all ────────────────────┐        │      │
│  │  │  ██████████████████████████████ │        │      │
│  │  └─────────────────────────────────┘        │      │
│  └─────────────────────────────────────────────┘      │
└───────────────────────────────────────────────────────┘

Reading a flamegraph:

  • Width = time - wider bars consumed more CPU time.
  • Vertical = call depth - bottom is the entry point, top is the leaf functions doing the actual work.
  • Look for wide bars near the top - these are the functions worth optimizing.
  • If malloc / alloc is wide, you're allocating too much. If memcpy is wide, you're copying too much.
Note
On macOS, cargo flamegraph uses dtrace which requires root. Run with sudo cargo flamegraph. Alternatively, use Instruments (Xcode) with the Time Profiler template for a GUI experience.

Zero-Copy Parsing Techniques

Our parser from Chapter 6 allocates a String for every header name and value. For high-throughput servers, these allocations dominate. Zero-copy parsing borrows slices from the original buffer instead of allocating:

// BEFORE: allocating parser - each field is an owned String
struct Request {
    method: String,        // allocated
    path: String,          // allocated
    headers: HashMap<String, String>,  // allocated per header
}

// AFTER: zero-copy parser - borrows from the raw buffer
struct RequestView<'buf> {
    method: &'buf str,     // points into the buffer
    path: &'buf str,       // points into the buffer
    headers: Vec<(&'buf str, &'buf str)>,  // all point into the buffer
}

impl<'buf> RequestView<'buf> {
    fn parse(raw: &'buf str) -> Option<Self> {
        let mut lines = raw.split("\r\n");
        let first = lines.next()?;
        let mut parts = first.split_whitespace();

        let method = parts.next()?;
        let path = parts.next()?;

        let headers: Vec<(&str, &str)> = lines
            .take_while(|l| !l.is_empty())
            .filter_map(|l| {
                let (k, v) = l.split_once(':')?;
                Some((k.trim(), v.trim()))
            })
            .collect();

        Some(RequestView { method, path, headers })
    }
}

The performance difference:

# Benchmark results (typical)
parse_allocating   time:   [1.031 µs]    7 allocations
parse_zero_copy    time:   [285.3 ns]    1 allocation (the Vec)
                   speedup: ~3.6x

The tradeoff: RequestView borrows from the buffer, so it can't outlive it. In our async server, we need the parsed request to live through routing and response building. The practical approach: zero-copy parse, then convert to owned types only at the boundary where data crosses task boundaries:

async fn handle_connection(mut stream: TcpStream, router: Arc<Router>) {
    let mut buffer = [0u8; 4096];
    let n = stream.read(&mut buffer).await.unwrap();
    let raw = std::str::from_utf8(&buffer[..n]).unwrap();

    // Zero-copy parse - no allocations yet
    let view = RequestView::parse(raw).unwrap();

    // Route using borrowed data (fast - within this function)
    let response = router.route_view(&view);

    // The view is dropped when this function returns.
    // If we needed to send it elsewhere, we'd call view.to_owned().
    stream.write_all(&response.to_bytes()).await.unwrap();
}
Tip
Don't start with zero-copy. Start with owned types (which we did), measure with benchmarks, and only switch to borrowing on the hot path where profiling shows allocations are the bottleneck. Premature zero-copy adds lifetime complexity everywhere for gains that might not matter.

Buffer Pooling and Allocation Strategies

Every connection allocates a [0u8; 4096] buffer on the stack. For the response, we build a Vec<u8> that's allocated and freed each time. Under high load, the allocator becomes a bottleneck. Buffer pooling reuses allocations:

src/pool.rs
use std::sync::Mutex;

/// A simple pool of reusable byte buffers.
pub struct BufferPool {
    buffers: Mutex<Vec<Vec<u8>>>,
    buffer_size: usize,
}

impl BufferPool {
    pub fn new(capacity: usize, buffer_size: usize) -> Self {
        let buffers = (0..capacity)
            .map(|_| Vec::with_capacity(buffer_size))
            .collect();
        BufferPool {
            buffers: Mutex::new(buffers),
            buffer_size,
        }
    }

    /// Get a buffer from the pool, or allocate a new one if empty.
    pub fn get(&self) -> Vec<u8> {
        self.buffers
            .lock()
            .unwrap()
            .pop()
            .unwrap_or_else(|| Vec::with_capacity(self.buffer_size))
    }

    /// Return a buffer to the pool for reuse.
    pub fn put(&self, mut buf: Vec<u8>) {
        buf.clear(); // reset length to 0, keep the allocation
        let mut pool = self.buffers.lock().unwrap();
        if pool.len() < 1000 {
            // cap the pool size to avoid unbounded growth
            pool.push(buf);
        }
        // else: drop the buffer - pool is full
    }
}

Use it in the connection handler:

use std::sync::Arc;

let buffer_pool = Arc::new(BufferPool::new(64, 4096));

// In the connection handler:
async fn handle(stream: &mut TcpStream, pool: &BufferPool) {
    let mut buf = pool.get();      // reuse a buffer
    buf.resize(4096, 0);
    let n = stream.read(&mut buf).await.unwrap();

    // ... process request, build response ...

    pool.put(buf);                 // return the buffer
}

Other Allocation Strategies

Pre-allocate with capacity

When you know the approximate size, use String::with_capacity(n) or Vec::with_capacity(n) to avoid reallocations as the collection grows.

// BAD: grows 4-5 times during construction
let mut response = String::new();
response.push_str("HTTP/1.1 200 OK\r\n");
// ...

// GOOD: one allocation
let mut response = String::with_capacity(256);
response.push_str("HTTP/1.1 200 OK\r\n");

Reuse across iterations

Clear and reuse instead of drop and recreate:

let mut buf = String::with_capacity(4096);
loop {
    buf.clear();  // length → 0, capacity stays
    // write into buf...
}

Stack over heap for small data

Use arrays ([u8; N]) instead of Vec<u8> when the size is known and small. Stack allocation is essentially free.

Load Testing Your Server

Benchmarks measure individual functions. Load tests measure the whole system under realistic conditions - concurrent connections, sustained throughput, tail latency.

wrk - HTTP Benchmarking Tool

# Install wrk (macOS)
brew install wrk

# Basic load test: 4 threads, 100 connections, 10 seconds
wrk -t4 -c100 -d10s http://127.0.0.1:8080/

# Output:
# Running 10s test @ http://127.0.0.1:8080/
#   4 threads and 100 connections
#   Thread Stats   Avg      Stdev     Max   +/- Stdev
#     Latency     1.23ms    0.45ms   12.3ms   92.10%
#     Req/Sec    20.5k      1.2k     24.1k    78.00%
#   820,000 requests in 10s, 125.3 MB read
# Requests/sec:  82,000
# Transfer/sec:  12.53 MB

What to Measure

MetricWhat it tells youWatch for
Requests/secThroughput - how many requests per secondDrops under load
Avg latencyTypical response timeCreeping up with more connections
P99 latencyWorst-case response time (99th percentile)Spikes - indicates GC pauses or lock contention
ErrorsFailed requests, timeouts, connection resetsAny non-zero count
MemoryRSS growth over timeUnbounded growth = leak

Progressive Load Testing

Don't just hit the server at max load. Ramp up gradually to find the breaking point:

# Warm up
wrk -t2 -c10 -d5s http://127.0.0.1:8080/

# Light load
wrk -t4 -c50 -d10s http://127.0.0.1:8080/

# Medium load
wrk -t4 -c200 -d10s http://127.0.0.1:8080/

# Heavy load
wrk -t4 -c500 -d10s http://127.0.0.1:8080/

# Stress test - find the breaking point
wrk -t8 -c1000 -d30s http://127.0.0.1:8080/

Plot requests/sec and P99 latency at each level. You're looking for the "knee" - the point where latency spikes and throughput plateaus. That's your server's practical capacity.

Warning
Always build with --release for load testing. Debug builds are 10–100x slower due to missing optimizations and extra bounds checking. cargo build --release then run the binary from target/release/.

Optimization Principles

1. Measure first, optimize second

Don't guess where the bottleneck is. Profile with flamegraphs, benchmark with Criterion, load test with wrk. Optimize the widest bar on the flamegraph, not the code that "looks slow."

2. Reduce allocations first

In Rust servers, allocation is usually the biggest cost. Use with_capacity, reuse buffers, borrow instead of clone. Check alloc in your flamegraph.

3. Avoid copies on the hot path

.clone(), .to_string(), and .to_vec() allocate. On a path that runs per-request, every copy adds up. Use references and Cow.

4. Minimize lock contention

Hold mutexes for the shortest possible time. Prefer RwLock over Mutex for read-heavy data. Use atomics for counters. Consider lock-free data structures for extreme throughput.

5. Don't optimize what doesn't matter

If your server handles 50 requests/sec and you need 100, optimizing parsing from 250ns to 100ns won't help - that's 0.003% of the request time. The bottleneck is elsewhere (database, network, business logic).

Exercise

  1. Set up Criterion benchmarks for Request::parse with three input sizes (minimal GET, typical POST, large body). Run cargo bench and record the baseline numbers.
  2. Implement the RequestView zero-copy parser. Add a Criterion benchmark comparing it to the allocating parser. What's the speedup?
  3. Generate a flamegraph of your server under load. Identify the top 3 functions by CPU time. Are any of them surprising?
  4. Add String::with_capacity to Response::to_bytes. Estimate the typical response size and pre-allocate. Benchmark before and after.
  5. Run a progressive load test (10, 50, 200, 500, 1000 connections). Find the knee where latency spikes. What's the limiting factor - CPU, memory, file descriptors, or lock contention?

What's Next

You now have the tools to measure, profile, and optimize your server. Criterion benchmarks give you precise function-level timings, flamegraphs show where CPU time goes, and load tests verify real-world performance.

In the final chapter, we package and deploy the server - release builds, cross-compilation, Docker containers, graceful shutdown, and running in production.