Parallélisation
Dans le chapitre sur les itérateurs, je vous ai promis que si vous preniez le
temps de vous familiariser avec les pipelines d’itération, vous seriez
récompensés avec une parallélisation par threads d’une incroyable facilité
via la bibliothèque rayon
. Il est temps de tenir cette promesse.
Démonstration
Ajoutez rayon
comme dépendance à votre projet…
cargo add rayon
…puis importez les traits dans le scope de votre programme…
use rayon::prelude::*;
Et maintenant, vous pouvez transformer vos pipelines d’itérateurs séquentiels en pipelines parallèles à peu de frais :
use rayon::prelude::*;
fn main() {
// Donnée d'exemple sans aucune prétention de réalisme
let v = vec![1.0f32; 1024 * 1024];
// Calcul parallèle de la somme des carrés des éléments
let resultat = v.par_iter()
.map(|x| x.powi(2))
.sum::<f32>();
println!("{resultat}");
}
Bien sûr, pour que ça marche, il faut que votre calcul soit bien thread-safe, sans accès en écriture à une variable déclarée en-dehors de l’itérateur par exemple.
Si vous essayez, la compilation échouera en vous indiquant quel accès mémoire n’est pas sûr en parallèle (c’est détecté car un tel accès viole forcément les règles d’emprunt partagé XOR mutable de Rust), et cela vous aidera à corriger votre code.
Explication
Sous le capot, rayon
a une implémentation analogue à celle de Intel TBB et du
langage Cilk. Le parallélisme est basé sur une approche fork-join avec
ordonnancement par vol de travail :
- On divise récursivement le travail en moitiés à peu près égales jusqu’à une certaine granularité.
- On traite le bloc actif, les autres blocs étant mis de côté dans une file de travail spécifique à chaque thread, mais accessible aux autres threads.
- Si un thread n’a pas de travail, il peut en voler dans la file de travail des autres threads, ce qui permet l’équilibrage dynamique de charge entre les threads.
Cette approche s’applique naturellement au parallélisme de données, c’est à dire au traitement parallèle des données d’une collection : on divise récursivement la collection en deux parties jusqu’à la bonne granularité, et après on distribue le travail selon l’algorithme précédent.
Ce découpage récursif est défini par un trait, implémenté par Rayon pour
toutes les collections de la bibliothèque standard. Quand on importe
rayon::prelude::*
, ce trait arrive dans le scope, donc on peut accéder à ses
méthodes par_iter()
etc, et aux implémentations fournies par rayon
pour les
collections de la bibliothèque standard.
D’autres bibliothèques fournissent du support rayon
intégré. Quand ce n’est
pas le cas, c’est facile de l’implémenter soi-même via l’itérateur parallèle
rayon::iter::split()
qui permet de diviser récursivement une collection de son choix en donnant
simplement la recette pour la couper en deux et la granularité souhaitée.