Skip to content
rusthacks.dev
Rust

Rust Is Not Always Faster and the Cases That Prove It

Rust gives you control over speed, not speed for free. Debug builds, extra clones, blocking calls and wrong algorithms can all make Rust code slower than a tuned alternative.

7 min read
Summarize in AI
Flat illustration for the post “Rust Is Not Always Faster and the Cases That Prove It”, composed around tooling.

"Rewrite it in Rust and it will be faster" is true often enough to be dangerous. It is false in ways that waste weeks: a debug build that runs slower than the Python it replaced, a rewrite that matches a C library you were already calling, an optimizer that deletes the loop you were trying to time. This post measures four of those cases on one machine so you can see how large the effects are. Every number below was produced by the programs shown, built with Rust 1.99 on an Apple silicon laptop, and the Python comparison used Python 3.14 without extra libraries.

The point is not that Rust is slow. It is that the language is one factor among several, and the others, the build mode, the data structure, which library actually does the work, usually dominate. Knowing which factor you are measuring is the skill.

Case one: the build mode changes the answer by a multiple

cargo run and cargo build compile in debug mode: no optimization, overflow checks, debug assertions. Programs in that mode are slow, and the slowness is not subtle. This program counts word frequencies two ways, with owned keys and with borrowed ones, and runs a tight arithmetic loop:

src/main.rsrust
use std::collections::HashMap;
use std::hint::black_box;
use std::time::Instant;

fn word_counts_cloning(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word.to_string()).or_insert(0) += 1;
    }
    counts
}

fn word_counts_borrowing(text: &str) -> HashMap<&str, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word).or_insert(0) += 1;
    }
    counts
}

/// Each iteration depends on the last, so the compiler cannot replace the loop with a formula.
fn xorshift_sum(n: u64) -> u64 {
    let mut x = 88172645463325252u64;
    let mut acc = 0u64;
    for _ in 0..n {
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        acc = acc.wrapping_add(x);
    }
    acc
}

fn main() {
    let text = "the quick brown fox jumps over the lazy dog ".repeat(200_000);

    let t = Instant::now();
    let a = word_counts_cloning(black_box(&text));
    println!("owned keys:     {:>8.1?}  ({} distinct)", t.elapsed(), a.len());

    let t = Instant::now();
    let b = word_counts_borrowing(black_box(&text));
    println!("borrowed keys:  {:>8.1?}  ({} distinct)", t.elapsed(), b.len());

    let t = Instant::now();
    let s = xorshift_sum(black_box(200_000_000));
    println!("xorshift loop:  {:>8.1?}  (result {s})", t.elapsed());
}

The loop is an xorshift random number generator accumulated 200 million times, a dependency chain that the compiler cannot collapse. Built with plain rustc (debug) and with rustc -O (release), and run three times each, the timings were steady to within a few percent:

Text
build     owned keys   borrowed keys   xorshift loop
debug       629 ms        534 ms          1.5 s
release      88 ms         34 ms          0.40 s

(Numbers are the fastest of three runs.) Same source, same result, and the loop is 3.8 times slower in debug. For code with iterator chains and generic collections the gap is larger, because abstractions that cost nothing in release mode are real function calls in debug. The word-count functions show it: the owned-key version took 629 ms in debug and 88 ms in release, a factor of 7.2, and the borrowed-key version took 534 ms against 34 ms, a factor of 16. A developer who benchmarks cargo run and concludes that Rust is slow has measured the debugger's build. Use cargo run --release or cargo bench for any comparison, and read Cargo release profile settings for what else the profile controls.

Case two: the optimizer can delete your benchmark

The first draft of this experiment summed squares over 200 million numbers, with a function like this:

src/main.rsrust
fn sum_squares(n: u64) -> u64 {
    (0..n).map(|i| i.wrapping_mul(i)).fold(0u64, |a, b| a.wrapping_add(b))
}

In release mode it reported 0.0ns and the right answer. The compiler had recognized that the sum of squares has a closed form and replaced the loop with a formula, so it ran 200 million iterations' worth of work in no time at all. Wrapping the input in std::hint::black_box did not stop it:

Text
sum of squares with black_box:     0.0ns (result 5323371213918391040)

That is an honest limitation of micro-benchmarks. When the result is correct and the time is zero, the compiler found a shortcut, which tells you about LLVM and nothing about the cost of the algorithm you thought you were timing. The xorshift loop above was chosen because each iteration depends on the last, so no formula exists and the work is real. To benchmark small functions, use a harness like criterion, and make the inputs vary and the outputs observable. A speedup that looks too good to be true is usually this.

Case three: the algorithm and the data structure beat the language

Compare the two word-count functions in release mode, built on the same text of 1.8 million words and eight distinct ones:

src/main.rsrust
fn word_counts_cloning(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word.to_string()).or_insert(0) += 1;
    }
    counts
}

fn word_counts_borrowing(text: &str) -> HashMap<&str, usize> {
    let mut counts = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word).or_insert(0) += 1;
    }
    counts
}
Text
owned keys:       88.0ms  (8 distinct)
borrowed keys:    34.5ms  (8 distinct)

Both are Rust, both are release builds, and one is 2.5 times faster. The first allocates a new String for every word, 1.8 million allocations, to use as a lookup key, even though only eight keys ever get stored. The second borrows slices of the original text and allocates nothing per word. Writing word.to_string() out of habit, or .clone() to make the borrow checker quiet, is the commonest way for correct Rust to lose its advantage. This is why profile-first advice exists: the allocator, not the language, was the cost.

Case four: the competitor was already compiled code

Now compare against Python on the same text, the way a rewrite decision gets made. Pure Python counting with a dictionary:

wordcount.pypython
import collections, time

text = "the quick brown fox jumps over the lazy dog " * 200_000

t = time.perf_counter()
c = collections.Counter(text.split())
print(f"Counter(text.split()): {(time.perf_counter() - t) * 1000:.1f} ms")

t = time.perf_counter()
d = {}
for w in text.split():
    d[w] = d.get(w, 0) + 1
print(f"python loop with dict: {(time.perf_counter() - t) * 1000:.1f} ms")
Text
Counter(text.split()): 136.8 ms
python loop with dict: 253.3 ms

The release Rust versions took 88 ms (owned keys) and 34 ms (borrowed). So Rust is 1.6 times faster than Python's Counter in the careless version and 4 times faster in the careful one, not 100 times. Counter is implemented in C, and str.split is too, so most of the work already runs at native speed; the Python interpreter is only involved in a handful of calls. That is typical of Python code that leans on library functions, and it is why a rewrite of such code, for speed alone, often disappoints.

Contrast the arithmetic loop. The same xorshift written as a Python for loop took 8.5 seconds for 20 million iterations, which extrapolates to about 85 seconds for 200 million. The Rust release build did the 200 million in 0.40 seconds. That is a factor of roughly 215, and it is the case where the interpreter executes every operation, which is exactly where compiled code wins by two orders of magnitude.

WorkloadPythonRust releaseRust advantage
Word count with Counter (C inside)137 ms34 to 88 ms1.6 to 4 times
Word count with a Python loop253 ms34 to 88 ms3 to 7 times
Tight arithmetic loop, 200M iterationsabout 85 s0.40 sabout 215 times

Read the table as a map of where the language matters. It matters a great deal when the program's own loop is the cost, and little when the program is glue around fast libraries.

Where Rust wins that these numbers do not show

Raw throughput on a single task is the weakest of Rust's advantages. The stronger ones appear elsewhere. Tail latency: with no garbage collector, a Rust service has no pause that lands on a random request, so the 99th percentile stays close to the median. Memory: a Rust program holds what it allocates, with no runtime heap overhead, so a service that needed 2 GB under a managed runtime may need a fraction of that. Predictability: the same code behaves the same under load, since there is no JIT warm-up and no background collector competing for cores. And parallelism without data races: the compiler lets you spread work across threads without the usual fear. None of these appears in a single-threaded benchmark on one core.

A checklist before blaming or crediting the language

  • Is the build --release? If not, stop.
  • Does the timed code do real work, or did the optimizer fold it away? Vary the input, use the result.
  • Is the Rust code allocating where it need not? A to_string(), clone() or collect() inside a loop is the first thing to check.
  • What is the baseline actually running? If it calls NumPy, a database or a C library, the comparison is to compiled code.
  • Does the speed matter to anyone? A batch job that runs for 40 ms is not a performance problem in either language.

If, after that, the program is CPU-bound, spends its time in its own loops and the numbers justify it, Rust is an excellent tool for the rewrite. The method for finding out what the time is spent on is in profiling Rust programs from flamegraph to a fix.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.