Paralelizar un bucle en Rust con rayon

Este es probablemente el ejemplo que mejor resume por qué existe Rust. Para repartir un bucle entre todos los núcleos del procesador no hay que crear hilos, ni repartir el trabajo, ni sincronizar nada: se cambia iter() por par_iter(). Y si el cambio introdujera una carrera de datos, el programa no compilaría.

La dependencia

cargo add rayon
[dependencies]
rayon = "1.12.0"

El mismo cálculo, en serie y en paralelo

use rayon::prelude::*;
use std::time::Instant;

fn es_primo(n: u64) -> bool {
    if n < 2 { return false; }
    let mut d = 2;
    while d * d <= n {
        if n % d == 0 { return false; }
        d += 1;
    }
    true
}

fn main() {
    let numeros: Vec<u64> = (2..600_000).collect();

    let t = Instant::now();
    let en_serie = numeros.iter().filter(|&&n| es_primo(n)).count();
    let serie = t.elapsed();

    let t = Instant::now();
    let en_paralelo = numeros.par_iter().filter(|&&n| es_primo(n)).count();
    let paralelo = t.elapsed();

    println!("primos encontrados: {en_serie} (en serie) y {en_paralelo} (en paralelo)");
    println!("en serie   : {:.2?}", serie);
    println!("en paralelo: {:.2?}", paralelo);
    println!("{:.1} veces más rápido en {} núcleos",
             serie.as_secs_f64() / paralelo.as_secs_f64(),
             rayon::current_num_threads());
}
cargo run --releaseprimos encontrados: 49098 (en serie) y 49098 (en paralelo)
en serie   : 46.00ms
en paralelo: 6.82ms
6.7 veces más rápido en 16 núcleos

La única diferencia entre las dos versiones es iter() frente a par_iter(). Todo lo demás —el filter, el count— es idéntico. rayon reparte el vector entre los núcleos disponibles, va robando trabajo de los hilos que terminan antes, y junta los resultados.

Los tiempos dependen de la máquina: esta tiene 16 núcleos y salen 6,7 veces más rápido, no 16, porque repartir y juntar también cuesta. En una máquina de cuatro núcleos la mejora será menor, y el programa seguirá funcionando igual sin tocar una línea.

Mídelo siempre con --release. En modo de depuración las comprobaciones adicionales distorsionan la comparación y los dos tiempos se parecen más de lo que deberían. Para cualquier medición de rendimiento, cargo run --release; hay más sobre esto en el ejemplo de medir el rendimiento.

Por qué esto es seguro

En otros lenguajes, paralelizar un bucle obliga a revisar a mano si el cuerpo toca algo compartido. Aquí no hace falta revisarlo porque el compilador no te deja equivocarte: para que par_iter() acepte tu cierre, lo que haya dentro tiene que poder cruzar hilos, y eso se comprueba con los traits Send y Sync de la lección de concurrencia.

Si el cuerpo del bucle modificara una variable compartida sin protección, el programa no compilaría. Esa es la diferencia real: no es que rayon sea cómodo, es que el cambio de una línea no puede introducir un error silencioso.

Lo que se usa a diario

En serie En paralelo
v.iter() v.par_iter()
v.iter_mut() v.par_iter_mut()
v.into_iter() v.into_par_iter()
for x in v v.par_iter().for_each(|x| ...)
v.sort() v.par_sort()
dos tareas seguidas rayon::join(a, b)
Paralelizar no siempre gana. Repartir el trabajo entre hilos tiene un coste fijo. Si el bucle recorre pocos elementos, o lo que hace con cada uno es trivial, la versión paralela puede salir más lenta que la secuencial. rayon rinde cuando hay bastante trabajo por elemento, como aquí, donde comprobar si un número es primo lleva lo suyo. Mide antes de dar por hecho que mejora.

El código de esta página se compiló y ejecutó con rustc 1.96.1 y rayon 1.12.0 antes de publicarla, en modo release sobre una máquina de 16 núcleos; los tiempos están copiados de esa ejecución y variarán en otra máquina. Documentación oficial: rayon en docs.rs.