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[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "parsing"
harness = false # Use Criterion's own main, not the built-in test harnessWrite benchmarks in 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.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 8080This 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/allocis wide, you're allocating too much. Ifmemcpyis wide, you're copying too much.
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.6xThe 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();
}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:
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 MBWhat to Measure
| Metric | What it tells you | Watch for |
|---|---|---|
| Requests/sec | Throughput - how many requests per second | Drops under load |
| Avg latency | Typical response time | Creeping up with more connections |
| P99 latency | Worst-case response time (99th percentile) | Spikes - indicates GC pauses or lock contention |
| Errors | Failed requests, timeouts, connection resets | Any non-zero count |
| Memory | RSS growth over time | Unbounded 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.
--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
- Set up Criterion benchmarks for
Request::parsewith three input sizes (minimal GET, typical POST, large body). Runcargo benchand record the baseline numbers. - Implement the
RequestViewzero-copy parser. Add a Criterion benchmark comparing it to the allocating parser. What's the speedup? - Generate a flamegraph of your server under load. Identify the top 3 functions by CPU time. Are any of them surprising?
- Add
String::with_capacitytoResponse::to_bytes. Estimate the typical response size and pre-allocate. Benchmark before and after. - 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.