R : paralléliser du code via le package doParallel

Comment exécuter du code en parallèle avec R ?

R est un outil puissant pour traiter des données, pourtant un grand nombre de personnes se retrouve bloqué par des faibles performances et frustré de ne pas pouvoir utiliser toutes les capacités de leur machine. Il existe cependant des packages R qui peuvent répondre aux besoins de chacun. Le package doParallel permet de paralléliser un code sur les différents coeurs d'une machine et ainsi de diviser le temps d'exécution de celui-ci.

Un premier exemple simple

Via cet exemple nous allons apprendre à implémenter un code pour qu'il s'exécute en parallèle. Nous allons utiliser la fonction system.time de R pour comparer les résultats.

library(doParallel)
library(dplyr)

cl <- detectCores() %>% -1 %>% makeCluster
registerDoParallel(cl)

Ce premier contenu de code initialise les coeurs de la machine :

  • detectCores récupère le nombre de coeurs composant la machine;
  • makeCluster crée les ensemble de copies de R;
  • registerDoParallel enregistre le backend parallèle pour la parallélisation du processus.

Nous conseillons d'utiliser maximum n-1 coeurs de la machine.

L'utilisation sur plusieurs coeurs est utile lorsque le temps d'exécution du code est conséquent, c'est pourquoi dans cet exemple nous recodons la fonction cumsum de R sans utiliser sum mais avec une boucle for.

cum_sum <- function(p) {
    return <- 0
    for (i in 1:p) {
        return <- return + i
    }
    return(return)
}
> n <- 20000
> system.time(lapply(1:n, cum_sum))
   user  system elapsed
 73.172   0.016  73.173
> system.time(foreach(e=1:n) %do% cum_sum(e))
   user  system elapsed
 96.428   0.020  96.422
> system.time(foreach(e=1:n) %dopar% cum_sum(e))
   user  system elapsed 
 23.432   0.736  33.988

lapply renvoie le résultat après 73.173 secondes contre 96.422 secondes avec la méthode foreach+do, mais on retient surtout l'efficacité de l'utilisation de foreach+dopar avec 33.988 secondes. Autrement dit, dans ce cas, l'utilisation du calcul parallèle permet de diviser par deux le temps de calcul.

Vous pouvez faire plusieurs tests de foreach et analyser les différentes façons de récupérer les résultats renvoyés en changeant le paramètre .combine.

Astuce : Si vous avez encore des doutes sur l'utilisation des coeurs de votre machine, je vous propose d'utiliser le moniteur système htop (pour les systèmes d’exploitation type Unix) depuis un terminal. Les tests ont été effectués avec une machine à 8 vCPUs, htop donne cette visualisation : doParallel-htop

On observe que tous les coeurs sont utilisés. Faites des tests avec et sans le package doParallel, lorsque le process est long à s'exécuter vous pourrez facilement constater les différences d'utilisation de votre machine.

Exemple orienté Machine Learning

Cet exemple se base sur le dataset public Iris de R. Nous allons utiliser le bootstrap pour générer un modèle de machine learning via la classification SVM. Bien-entendu, notre exemple reste encore simple car nous nous contenterons d'exécuter la phase d'apprentissage du modèle, mais vous pourrez ensuite l'utiliser avec vos données. Nous allons donc tester 10 itérations du modèle sur un ré-échantillonnage des données. L'échantillon utilisé est plus important que la donnée de base, mais cela permet de simuler le cas où on se retrouverait avec une quantité de données plus importante. Si le jeu de données est faible en nombre de ligne, la fonction svm prendra très peu de temps et l'exécuter en parallèle ne sera pas plus efficace.

Cette méthode peut être utilisée dans le cadre d'une validation de modèle de type K-fold cross-validation par exemple.

Initialisation et exécution :

library(doParallel)
library(dplyr)
library(e1071)

cl <- detectCores() %>% -1 %>% makeCluster
registerDoParallel(cl)

n <- 10

time_for <- system.time({
    for (i in 1:n) {
        ind <- sample(nrow(iris), 50000, replace=TRUE)
        svm(iris[ind, c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width")], iris[ind, c("Species")])
    }
})

time_foreach <- system.time({
    foreach(icount(n), .packages=c("e1071")) %dopar% {
        ind <- sample(nrow(iris), 50000, replace=TRUE)
        svm(iris[ind, c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width")], iris[ind, c("Species")])
    }
})

Vous remarquerez l'utilisation du paramètre .packages de foreach qui permet d'utiliser le package e1071 sur tous les coeurs.

Résultat :

> print(time_foreach)
   user  system elapsed 
  0.940   0.084  33.576 
> print(time_for)
   user  system elapsed 
160.540   0.244 160.780

Le résultat est sans appel. Il est clair que le temps d'exécution en parallèle est beaucoup plus rapide qu'une boucle.

Nous savons maintenant comment implémenter en R un code qui va s'exécuter sur différents coeurs d'une machine via le package doParallel. Il ne faut pas perdre de vue que paralléliser un processus prend du temps à la machine et qu'il est donc parfois inutile d'essayer de le faire. C'est pourquoi nous n'exécutons pas des tâches de quelques secondes.

N'hésitez pas à commenter afin de partager vos expériences autour de ces sujets.