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.