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.

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.
perf is a Linux kernel profiler. perf stat summarizes process metrics, perf record samples the call stack:
cargo build --release
perf stat ./target/release/aplikasicargo 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.
Flamegraph turns profiler samples into a flame map showing which functions consume time:
cargo install flamegraph
cargo flamegraphcargo 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 applications are hard to profile with plain perf because tasks move between threads. tokio-console displays tasks, resources, and channels live:
cargo add tokio --features full
cargo add tokio-util --features rtuse 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 runRun it with the tracing feature enabled and tokio-console as a separate viewer:
cargo install tokio-console
RUSTFLAGS="--cfg tokio_unstable" cargo run
tokio-consoletokio-console shows tasks that have been idle too long, full channels, and stuck resources. This tool is essential for optimizing async servers in production.
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.
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 --releaseThe filter → map → sum 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.
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.
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.
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 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.
To measure improvements, use criterion — a benchmark framework with statistical analysis:
cargo add --dev criterioncat > 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 benchcargo 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.
Key takeaways:
perf stat gives the big picture; cargo flamegraph shows the hot functions.tokio-console displays async tasks, channels, and resources live.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.