Ir al contenido

Rendimiento en Rust

Rust tiene fama de ser rápido, y se la ha ganado. Pero “Rust es rápido” no significa que cualquier programa en Rust sea rápido. Un algoritmo lento es lento en cualquier lenguaje, y algunas costumbres te pueden costar mucho sin que te des cuenta.

La buena noticia: casi todas las grandes mejoras son sencillas. Compila en modo release. Mide antes de cambiar nada. No copies datos que solo necesitas leer. Elige la colección adecuada. Esta página repasa cada una, con mediciones reales donde ayudan. Piénsalo como poner a punto una bici: primero comprueba que las ruedas tienen aire (modo release), y después preocúpate por piezas más ligeras.

Antes de hacer algo más rápido, necesitas saber cuánto tarda. std::time::Instant es un cronómetro que viene en la biblioteca estándar. Llama a Instant::now() antes del trabajo y a .elapsed() después:

use std::time::Instant;
fn main() {
let start = Instant::now();
// Genera 5 millones de números pseudoaleatorios con un xorshift sencillo.
let mut x: u64 = 12345;
let mut numbers = Vec::with_capacity(5_000_000);
for _ in 0..5_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
numbers.push(x % 1_000_000);
}
numbers.sort();
let sum: u64 = numbers.iter().take(1000).sum();
println!("los 1000 más pequeños suman {sum}");
println!("tardó {:?}", start.elapsed());
}

El formato {:?} imprime un Duration de forma legible, como 250.271745ms. Aquí no hay botón para probar el código, y es a propósito: los tiempos cambian en cada ejecución y en cada máquina, así que no se pueden comparar con una salida fija. Ejecútalo en tu propio ordenador con cargo run.

Este es el consejo de rendimiento más importante en Rust, y es solo una opción de la línea de comandos.

Terminal window
cargo run # build de debug: compila rápido, se ejecuta lento
cargo run --release # build de release: compila más lento, se ejecuta mucho más rápido

Un build de debug se salta casi todas las optimizaciones para compilar rápido y ser fácil de depurar. Además comprueba el desbordamiento de enteros. Un build de release activa el optimizador al máximo.

Esta es una medición de ejemplo: ejecutamos el programa de arriba tres veces de cada forma en la máquina donde escribimos esta página (un servidor en la nube con una CPU Intel Xeon). Tus números serán distintos; lo único que importa es la diferencia entre ellos:

build de debug: tardó 5.02s, 4.27s, 4.83s
build de release: tardó 250ms, 297ms, 265ms

Eso es más o menos entre 15 y 20 veces más rápido, con una sola opción. La diferencia depende mucho del código: los bucles y los iteradores son los que más ganan. Pero nunca es tan pequeña como para ignorarla.

Así que la regla es simple: nunca juzgues la velocidad con un build de debug. Si alguien dice “Rust es lento” después de medir con cargo run, casi siempre es por esto.

.clone() hace una copia completa. Para un String o un Vec, eso significa reservar memoria nueva en el heap y copiar cada byte. A veces necesitas una copia, pero muchas veces solo necesitas leer los datos, y un préstamo (borrowing) no cuesta nada.

El caso más común son los parámetros de las funciones. Si una función solo lee una cadena de texto, recibe &str, no String:

fn count_long_words(text: &str, min_len: usize) -> usize {
text.split_whitespace().filter(|w| w.len() >= min_len).count()
}
fn main() {
let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String se convierte en &str automáticamente, sin copiar nada.
println!("{}", count_long_words(&owned, 5));
// Un literal de cadena ya es un &str.
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Nunca entregamos `owned`, así que todavía se puede usar.
println!("{owned}");
}

Salida:

3
2
the quick brown fox jumps over the lazy dog
▶ Pruébalo tú mismo

Las frases están en inglés a propósito: la función cuenta palabras según su longitud en bytes (w.len()), y las letras con tilde ocupan más de un byte en UTF-8, lo que cambiaría el resultado.

Un parámetro &str acepta tanto un String (prestado con &) como un literal de cadena. Un parámetro String obligaría a quien llama a entregar su cadena o a clonarla. La misma idea vale para las listas: recibe &[T] en vez de Vec<T>. Encontrarás los detalles en Borrowing y Slices.

Vigila también los clones dentro de los bucles. for name in names.clone() copia la lista entera solo para mirarla. for name in &names la toma prestada gratis.

Cuando un Vec se queda sin espacio, reserva un bloque más grande y mueve todo allí. Si conoces el tamaño de antemano, Vec::with_capacity lo reserva de una sola vez. Este programa cuenta cuántas veces tuvo que crecer cada Vec al meter 1000 números:

fn count_growths(mut v: Vec<u32>, n: u32) -> u32 {
let mut growths = 0;
let mut cap = v.capacity();
for i in 0..n {
v.push(i);
if v.capacity() != cap {
growths += 1;
cap = v.capacity();
}
}
growths
}
fn main() {
println!("Vec::new(): creció {} veces", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity(): creció {} veces", count_growths(Vec::with_capacity(1000), 1000));
}

Salida:

Vec::new(): creció 9 veces
Vec::with_capacity(): creció 0 veces
▶ Pruébalo tú mismo

Crecer nueve veces no es un desastre: el Vec duplica su tamaño cada vez, así que sigue siendo barato. Pero en un bucle muy usado que construye millones de vectores, se nota. (El patrón exacto de crecimiento es un detalle interno de la biblioteca estándar y podría cambiar.)

Aún más fácil: deja que collect() y join() hagan el trabajo. A menudo saben el tamaño final y reservan memoria una sola vez:

fn main() {
// collect() construye el Vec de una vez.
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join() calcula primero la longitud total y luego copia una sola vez.
let colors = ["rojo", "verde", "azul"];
println!("{}", colors.join(", "));
}

Salida:

[1, 4, 9, 16, 25]
rojo, verde, azul
▶ Pruébalo tú mismo

Quizá te preocupe que .iter().filter().map().sum() sea más lento que un bucle normal, porque parecen muchas llamadas a funciones. No lo es. En un build de release, el compilador integra (hace inline de) todos esos closures pequeños, y obtienes el mismo código máquina ajustado que con un bucle escrito a mano. Rust lo llama abstracción de coste cero (zero-cost abstraction): no pagas nada por el estilo más bonito.

fn sum_even_squares_loop(nums: &[u64]) -> u64 {
let mut total = 0;
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
total += nums[i] * nums[i];
}
}
total
}
fn sum_even_squares_iter(nums: &[u64]) -> u64 {
nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()
}
fn main() {
let nums: Vec<u64> = (1..=10).collect();
println!("bucle: {}", sum_even_squares_loop(&nums));
println!("iter: {}", sum_even_squares_iter(&nums));
}

Salida:

bucle: 220
iter: 220
▶ Pruébalo tú mismo

La versión con iteradores incluso puede ser un poco más rápida. Con nums[i], Rust comprueba en cada acceso que i está dentro del slice. El iterador ya sabe que no se puede salir de los límites, así que esas comprobaciones desaparecen. Usa lo que se lea mejor, que normalmente es el iterador.

Elegir bien la estructura de datos suele ganar a cualquier ajuste fino. Un ejemplo clásico: preguntar muchas veces “¿está este elemento en mi lista?”.

  • Vec::contains mira cada elemento, uno por uno. Las búsquedas se vuelven más lentas a medida que la lista crece.
  • HashSet::contains (y HashMap::get) salta casi directamente a la respuesta. Las búsquedas siguen siendo rápidas sea cual sea el tamaño.
use std::collections::HashSet;
fn main() {
let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect();
let queries: Vec<u32> = (0..20_000).collect();
// Lento con listas grandes: cada contains() recorre el Vec entero.
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Rápido: construye un HashSet una vez y cada búsqueda es rápida.
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec encontró {hits_vec}, HashSet encontró {hits_set}");
}

Salida:

Vec encontró 6667, HashSet encontró 6667
▶ Pruébalo tú mismo

La misma respuesta, con un coste muy distinto. Añadimos temporizadores Instant alrededor de cada parte y ejecutamos un build de release. Esta es una medición de ejemplo de nuestra máquina:

Vec: 6667 aciertos en 27.97ms
HashSet: 6667 aciertos en 551.60µs

Unas 50 veces más rápido, y eso incluyendo el tiempo de construir el conjunto. Eso sí, para una lista de 10 elementos un Vec va perfectamente e incluso puede ganar. Aprende más en las páginas de HashSet y HashMap.

A los programadores se nos da famosamente mal adivinar dónde se va el tiempo. Así que no adivines: perfila (profiling). Un profiler observa tu programa mientras se ejecuta y te dice qué funciones se llevan más tiempo. Normalmente son uno o dos puntos calientes, y el resto de tu código no importa nada.

Dos profilers populares y gratuitos que funcionan con Rust:

Terminal window
# flamegraph: dibuja dónde se gasta el tiempo (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: abre los resultados en el Firefox Profiler, en tu navegador
cargo install samply
cargo build --release
samply record ./target/release/my_app

Los profilers necesitan símbolos de depuración para mostrar los nombres de las funciones. Añade esto a Cargo.toml para que los builds de release los conserven (no hace el código más lento, solo el archivo más grande):

[profile.release]
debug = true

Medir algo una sola vez con Instant da resultados con mucho ruido. Un benchmark ejecuta el código muchas veces y usa estadística para darte un número fiable. El #[bench] que trae Rust solo funciona en nightly, así que casi todo el mundo usa el crate criterion (en inglés). Se configura así:

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

Pon el código que quieres medir en tu biblioteca (src/lib.rs) y escribe el benchmark en benches/fib_bench.rs:

use criterion::{criterion_group, criterion_main, Criterion};
use my_crate::fibonacci;
use std::hint::black_box;
fn bench_fib(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);

black_box impide que el compilador se pase de listo y calcule la respuesta por adelantado, lo que dejaría el benchmark sin sentido. Ejecútalo con:

Terminal window
cargo bench

Un resultado de ejemplo de nuestra máquina (criterion muestra una estimación baja, la mejor estimación y una estimación alta):

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

Vuelve a ejecutarlo después de un cambio y criterion te dirá si el código se volvió más rápido o más lento, y si la diferencia es real o solo ruido.

Los ajustes de release por defecto son buenos. Cuando necesitas un poco más, estas son las opciones habituales en Cargo.toml:

[profile.release]
opt-level = 3 # el valor por defecto en release; "s" o "z" optimizan para tamaño
lto = true # optimización en tiempo de enlazado: optimiza entre todos los crates (build más lento)
codegen-units = 1 # una unidad = más ocasiones de optimizar, pero compila más lento
panic = "abort" # sin código de unwinding; binario más pequeño, pero no se pueden capturar los panics

Normalmente dan unos pocos puntos porcentuales, no milagros, y hacen los builds más lentos. Pruébalas de una en una y mide cada una. lto = "thin" es un término medio popular que compila más rápido que lto = true.

Medir un build de debug. Ya lo vimos, pero es el error número uno: mide siempre con --release.

Clonar para contentar al compilador. Imagina que una función recibe un String y la llamas con un préstamo:

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

El compilador dice:

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

El mensaje dice que los tipos no coinciden: la función esperaba un String y recibió un &String. Es tentador escribir shout(name.clone()). Funciona, pero copia la cadena en cada llamada. La mejor solución es cambiar el parámetro a text: &str: así shout(&name) compila y no se copia nada.

println! dentro de un bucle muy usado. Cada println! bloquea la salida estándar y escribe la línea en ese momento. Para unas pocas líneas no pasa nada. Para un millón, es lento. Envuelve stdout en un BufWriter para que escriba en bloques grandes:

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "línea {i}").unwrap();
}
}

Salida:

línea 1
línea 2
línea 3
línea 4
línea 5
▶ Pruébalo tú mismo

En nuestra medición de ejemplo, imprimir un millón de líneas en un archivo tardó unos 570–670 ms con println! y unos 40–60 ms con BufWriter (build de release).

  • Mide siempre la velocidad con cargo run --release: los builds de debug pueden ser muchas veces más lentos.
  • Usa Instant::now() y .elapsed() para medir rápido, y criterion para benchmarks fiables.
  • Toma prestado (&str, &[T]) en vez de clonar cuando solo necesitas leer los datos.
  • Reserva memoria una vez: Vec::with_capacity, collect() y join() evitan que el vector crezca una y otra vez.
  • Los iteradores no cuestan nada; usa un HashSet/HashMap cuando hagas muchas búsquedas.
  • Primero perfila, y luego optimiza los puntos calientes que te muestre el profiler.

Comprueba lo aprendido

1. Tu programa parece lento cuando lo ejecutas con cargo run. ¿Qué deberías probar primero?

2. Una función solo lee una cadena de texto. ¿Qué tipo de parámetro evita copias innecesarias?

3. Necesitas comprobar si 50.000 IDs están en una lista de 50.000 IDs prohibidos. ¿Qué es mejor para la lista de prohibidos?

4. ¿Qué significa que "los iteradores no cuestan nada"?

5. ¿Por qué un benchmark de criterion usa black_box?

Puntuación: 0 / 5