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.
Paso cero: medir con Instant
Sección titulada «Paso cero: medir con Instant»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.
Builds de debug y de release
Sección titulada «Builds de debug y de release»Este es el consejo de rendimiento más importante en Rust, y es solo una opción de la línea de comandos.
cargo run # build de debug: compila rápido, se ejecuta lentocargo run --release # build de release: compila más lento, se ejecuta mucho más rápidoUn 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.83sbuild de release: tardó 250ms, 297ms, 265msEso 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.
Toma prestado en vez de clonar
Sección titulada «Toma prestado en vez de clonar».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:
32the quick brown fox jumps over the lazy dogLas 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.
Reserva memoria una vez, no una y otra vez
Sección titulada «Reserva memoria una vez, no una y otra vez»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 vecesVec::with_capacity(): creció 0 vecesCrecer 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, azulLos iteradores no cuestan nada
Sección titulada «Los iteradores no cuestan nada»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: 220iter: 220La 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.
Elige la colección adecuada
Sección titulada «Elige la colección adecuada»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::containsmira cada elemento, uno por uno. Las búsquedas se vuelven más lentas a medida que la lista crece.HashSet::contains(yHashMap::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ó 6667La 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.97msHashSet: 6667 aciertos en 551.60µsUnas 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.
Perfila antes de optimizar
Sección titulada «Perfila antes de optimizar»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:
# flamegraph: dibuja dónde se gasta el tiempo (Linux, macOS)cargo install flamegraphcargo flamegraph
# samply: abre los resultados en el Firefox Profiler, en tu navegadorcargo install samplycargo build --releasesamply record ./target/release/my_appLos 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 = trueBenchmarks con criterion
Sección titulada «Benchmarks con criterion»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 = falsePon 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:
cargo benchUn 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.
Ajustes del perfil de release
Sección titulada «Ajustes del perfil de release»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ñolto = 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 lentopanic = "abort" # sin código de unwinding; binario más pequeño, pero no se pueden capturar los panicsNormalmente 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.
Errores comunes
Sección titulada «Errores comunes»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 incorrectEl 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 1línea 2línea 3línea 4línea 5En 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).
Resumen
Sección titulada «Resumen»- 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()yjoin()evitan que el vector crezca una y otra vez. - Los iteradores no cuestan nada; usa un
HashSet/HashMapcuando hagas muchas búsquedas. - Primero perfila, y luego optimiza los puntos calientes que te muestre el profiler.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»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
Para leer más
Sección titulada «Para leer más»- The Rust Performance Book (en inglés): una guía corta y práctica sobre ajustes de compilación, profiling y mejoras de velocidad habituales.
- Comparing Performance: Loops vs. Iterators — The Rust Book (en inglés): por qué los iteradores compilan a código rápido.
- Profiles — The Cargo Book (en inglés): todos los ajustes de
[profile], con su valor por defecto. std::time::Instant(en inglés): el cronómetro de la biblioteca estándar.- Documentación de criterion (en inglés): el crate de benchmarks que usamos arriba.