Learning Rust - Performance Tuning & Profiling
Episode 13 of 19

Learning Rust - Performance Tuning & Profiling

This episode optimizes your Rust application: profiling with perf, tokio-console, and cargo flamegraph, leveraging zero-cost abstractions and iterators, as well as handling I/O, allocation, and lock contention bottlenecks with the help of criterion for benchmarks.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Rust is known for being fast, but that speed does not come by itself — it comes from measurement. Episode 13 discusses profiling and performance tuning: how to find where time is actually spent, then optimize the parts that matter.

We start from a mindset: optimization without measurement is a guess. Then we use the profiling tools perf, tokio-console, and cargo flamegraph, understand zero-cost abstractions, and handle three classic bottlenecks: I/O, allocation, and lock contention.

Profiling with perf and Cargo Flamegraph

perf for CPU Sampling

perf is a Linux kernel profiler. perf stat summarizes process metrics, perf record samples the call stack:

Process statistics
cargo build --release
perf stat ./target/release/aplikasi

cargo build --release creates an optimized binary — profiling should always use a release build. perf stat ./target/release/aplikasi displays CPU usage, cache misses, and branch mispredictions. Start here to get the big picture.

Visualization with Cargo Flamegraph

Flamegraph turns profiler samples into a flame map showing which functions consume time:

Creating a flamegraph
cargo install flamegraph
cargo flamegraph

cargo flamegraph runs the binary and produces flamegraph.svg. The ribbon length shows the time spent by each function. cargo install flamegraph installs the tool once; the resulting SVG can be opened in a browser and explored.

Tokio-Console for Async

Viewing Async Tasks

Tokio applications are hard to profile with plain perf because tasks move between threads. tokio-console displays tasks, resources, and channels live:

Enabling tokio-console
cargo add tokio --features full
cargo add tokio-util --features rt
Runtime with console
use tokio_util::task::TaskTracker;
 
#[tokio::main]
async fn main() {
    let tracker = TaskTracker::new();
    tracker.spawn(async {
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
        println!("task selesai");
    });
    tracker.close();
    tracker.wait().await;
}
EOF
cargo run

Run it with the tracing feature enabled and tokio-console as a separate viewer:

Running the console
cargo install tokio-console
RUSTFLAGS="--cfg tokio_unstable" cargo run
tokio-console

tokio-console shows tasks that have been idle too long, full channels, and stuck resources. This tool is essential for optimizing async servers in production.

Zero-Cost Abstractions and Iterators

Abstractions Without Overhead

Rust promises abstractions that add no runtime cost: generics are monomorphized at compile time, and iterators are usually optimized into loops equivalent to handwritten code. This means you can write expressive code without fearing overhead.

Using Iterators Efficiently

An iterator pipeline
cat > src/main.rs <<'EOF'
fn main() {
    let data = (0..1_000_000u64).collect::<Vec<_>>();
 
    let jumlah: u64 = data
        .iter()
        .filter(|n| n % 2 == 0)
        .map(|n| n * 3)
        .sum();
 
    println!("jumlah: {}", jumlah);
}
EOF
cargo run --release

The filtermapsum pipeline is compiled into a single efficient loop, not a series of intermediate allocations. cargo run --release runs the optimized version. Always use iterator adapters instead of manual loops for collection operations — unless profiling shows this is genuinely a bottleneck.

Avoiding Unnecessary Allocations

String::from and to_string allocate on the heap. For one-off formatting, format! is fine; in hot loops, consider writing to a reused Vec<u8>. The stack-vs-heap knowledge from episode 6 pays off directly here.

Handling I/O, Allocation, and Lock Bottlenecks

I/O: Batch and Go Non-Blocking

I/O is slow because it waits, not because it computes. Strategies: batch small operations into one large one, use async I/O (episode 8), and avoid blocking the async runtime with synchronous work. If an async server slows down, check whether synchronous calls are blocking the thread.

Allocation: Size and Behavior

Excessive allocation triggers GC-free pressure but is still expensive because of allocator calls and page faults. Monitor with a flamegraph: a wide ribbon in an allocator function indicates a problem. Consider data structures such as Vec::with_capacity, which allocate once with a known size.

Lock Contention: Reduce Waiting

Lock contention happens when many tasks fight over the same mutex. Its symptom: a flamegraph shows lots of time in lock. The remedies are layered: shorten the critical section, use RwLock for read-heavy workloads, or replace the Arc<Mutex> pattern with the actor pattern (episode 12), which removes the lock entirely.

Benchmarking with Criterion

Writing Benchmarks

To measure improvements, use criterion — a benchmark framework with statistical analysis:

Adding criterion
cargo add --dev criterion
A simple benchmark
cat > benches/jumlah.rs <<'EOF'
use criterion::{criterion_group, criterion_main, Criterion};
 
fn jumlah_paralel(n: u64) -> u64 {
    (0..n).filter(|x| x % 2 == 0).map(|x| x * 3).sum()
}
 
fn bench(c: &mut Criterion) {
    c.bench_function("jumlah_paralel", |b| {
        b.iter(|| jumlah_paralel(1_000_000))
    });
}
 
criterion_group!(benches, bench);
criterion_main!(benches);
EOF
cargo bench

cargo bench runs the benchmark and shows the estimated time per iteration. Criterion records a baseline, so every code change can be measured as faster or slower. cargo add --dev criterion adds it as a dev-dependency.

Closing

Key takeaways:

  • Optimization always starts from measurement, not guessing.
  • perf stat gives the big picture; cargo flamegraph shows the hot functions.
  • tokio-console displays async tasks, channels, and resources live.
  • Iterators and generics are zero-cost; use them without hesitation.
  • Classic bottlenecks: blocking I/O, excessive allocation, and lock contention.
  • Criterion makes repeatable benchmarks with a measurable baseline.

In the next episode 14 we will discuss observability, logging, and tracing — logging with tracing and log plus the subscriber ecosystem, distributed tracing with tracing-opentelemetry, and Prometheus metrics via prometheus or opentelemetry. You will see what is happening inside your service.

Learning Rust - Performance Tuning & Profiling | Learning Rust