Achieving Zero-Cost Abstractions in Rust with Iterators and Generics
Learn how Rust's iterator chains and generic functions compile to assembly equivalent to hand-written loops, giving high-level code zero runtime overhead.
23 Oct 2025, 20:40 UTC

When writing performance‑critical Rust code, developers often face a choice between readable high‑level constructs and the speed of hand‑written loops. Rust’s zero‑cost abstraction promise lets you keep the former without paying a runtime penalty, provided you use static dispatch via generics and leverage the lazy Iterator trait. This guide shows how to obtain assembly equivalent to a manual loop while retaining expressive code.
Desired Outcome
Replace a manual for loop that processes a collection with a generic function that accepts any iterator, chains filter and map adapters, and verify that the generated machine code contains no function calls and matches the loop’s efficiency.
Prerequisites
- Rust toolchain stable ≥ 1.70 (for
cargo asmsupport). - A Cargo project with a binary or library target.
- Optional: install
cargo-asm(cargo install cargo-asm) to inspect assembly. - Basic familiarity with Rust syntax and Cargo commands.
Focused Procedure
- Set up the project
Create a new library if you do not have one:
cargo new --lib perf_iterators cd perf_iteratorsAdd
cargo-asmas a development dependency if you want to inspect assembly directly from Cargo:[dev-dependencies] cargo-asm = "0.9" - Write a generic iterator‑based function
Place the following code in
src/lib.rs. It works with any type that yieldsu32items./// Process an iterator of u32: keep even numbers and square them. /// The function is generic over any IntoIterator. fn process_data(data: I) -> Vec where I: IntoIterator, { data.into_iter() .filter(|&x| x % 2 == 0) .map(|&x| x * x) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn basic() { let src = vec![1, 2, 3, 4, 5, 6]; assert_eq!(process_data(src), vec![4, 16, 36]); } }This function does not allocate intermediate vectors; the iterator chain is lazy.
- Create a manual‑loop baseline for comparison
Add a second function that performs the same work with an explicit
forloop.fn process_data_manual(data: &[u32]) -> Vec { let mut out = Vec::with_capacity(data.len()); for &x in data { if x % 2 == 0 { out.push(x * x); } } out } #[cfg(test)] mod manual_tests { use super::*; #[test] fn manual_matches() { let src = vec![1, 2, 3, 4, 5, 6]; assert_eq!(process_data(src), process_data_manual(&src)); } } - Inspect the generated assembly
Run the following command in the project root. It requires read access to the
target/directory.cargo asm --release --lib process_dataLook for a tight loop that increments an index, loads a value, tests evenness, squares, and stores the result. There should be no call instructions inside the loop body.
Repeat the command for
process_data_manualand compare the two listings; they should be structurally identical. - Benchmark the two implementations
Add the
criterioncrate to[dev-dependencies]if you prefer a full‑featured harness, or use the built‑intestbenchmark feature.[dev-dependencies] criterion = "0.5"Create
benches/iter_vs_manual.rs:use criterion::{criterion_group, criterion_main, Criterion}; use perf_iterators::{process_data, process_data_manual}; fn bench_iter(c: &mut Criterion) { let data: Vec = (0..1_000_000).map(|x| x as u32).collect(); c.bench_function("iter", |b| b.iter(|| process_data(&data))); } fn bench_manual(c: &mut Criterion) { let data: Vec = (0..1_000_000).map(|x| x as u32).collect(); c.bench_function("manual", |b| b.iter(|| process_data_manual(&data))); } criterion_group!(benches, bench_iter, bench_manual); criterion_main!(benches);Run the benchmark:
cargo benchCheck the output; the median times for
iterandmanualshould be within a few percent of each other, indicating zero‑cost abstraction. - Verify correctness across generic boundaries
Run the unit tests to ensure the generic function works for different iterator types (slice, vector, range).
cargo test --releaseAll tests should pass, confirming that monomorphization did not introduce logic errors.
Expected Checks
- Assembly inspection shows no function calls inside the hot loop for both implementations.
- Benchmark results indicate comparable performance (difference < 5 %).
- Unit tests pass for slice,
Vec, andstd::ops::Rangeinputs.
Recovery Options and Limitations
- Binary bloat: Monomorphization creates a copy of
process_datafor each distinct concrete type used. If you call the function with hundreds of different iterator types, compile time and binary size may grow. To mitigate, keep the number of distinct types low or wrap the function in a thin trait object (Box Vec) only when flexibility is essential, accepting the small vtable overhead. - Complex error messages: Deeply nested iterator chains can produce hard‑to‑read type errors. If you encounter such errors, break the chain into intermediate variables with explicit types.
- Dynamic dispatch pitfalls: Avoid placing
dyn Traitinside loops that run millions of times; the vtable lookup will inhibit inlining. Prefer generic parameters (T: Iterator) for hot paths. - Checking results: After any change, re‑run
cargo asm --releaseon the affected function and compare the loop structure to the baseline. Re‑runcargo benchto ensure performance regression has not appeared.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.