Schleifen parallelisieren in R mit foreach

Pexels: Chait Goli

Schleifen haben einen schlechten Ruf in R: Sie gelten nicht zu unrecht als langsam. Oft ist es möglich, Schleifen zu vermeiden, etwa durch vektorisierte Funktionen, mit Funktionen aus der apply-Familie (wie lapply) oder mit map-Funktionen aus dem purrr-Paket.

Manchmal wäre es jedoch recht aufwändig, R-Code so umzuschreiben, dass Schleifen eliminiert werden. Dann ist es nützlich, ein Werkzeug im Werkzeugkoffer zu haben, das die Schleifen zwar behält, aber durch Parallelisierung beschleunigt. Das foreach-Paket von Michelle Wallig und Steve Weston bietet genau das.

Schleifen parallelisieren in R mit foreach

Vorbereitung: Daten und Regressionsfunktion

Wir nutzen das gleiche Beispiel wie im früheren Beitrag R-Code parallelisieren mit parallel::clusterApply(): simulierte Daten mit 90 Fällen und 200 unabhängigen Variablen von X1 bis X200 sowie einer abhängigen Variable result. Ziel ist es wieder, für jeden der 200 Prädiktoren je ein Regressionsmodell aufzustellen.

set.seed(2020)
data <- data.frame(replicate(200, sample(0:100, 90, replace = TRUE)))
data$result <- sample(1:50, size = 90, replace = TRUE)

IVs <- names(data[-length(data)])

Im Vektor IVs (für independent variables) legen wir die unabhängigen Variablen als Texte (character strings) ab. [-length(data)] schließt die letzte Variable aus, die als abhängige Variable in den folgenden Regressionsmodellen dient.

Nun definieren wir eine Funktion, die eine unabhängige Variable als Argument annimmt und das summary eines Regressionsmodells zurückliefert (analog zum oben verlinkten Beitrag).

reg <- function(IV) {
   model <- as.formula(paste("result ~", IV))
   summary(lm(model, data = data))
}

Wir können die Funktion testen – direkt oder mit einer Variable:

reg("X1")

IV <- "X1"
reg(IV)

Schleifen: Base R, for loops

In Base R können wir folgende Schleife nutzen, um die 200 Modelle zu erstellen:

for (i in 1:length(IVs)) {
   IV <- IVs[i]
   print(reg(IV))
}

Innerhalb von Schleifen benötigen wir ein ausdrückliches print-Statement, um Ergebnisse in der Konsole zu sehen. (Für dieses Beispiel würde ich den lapply-Ansatz vorziehen, wie im Beitrag über clusterApply() gezeigt. Er ist kürzer, eleganter und schneller: lapply(IVs, reg). Nicht immer ist es so einfach, Schleifen zu vermeiden, daher ist es sinnvoll, sie auch im Repertoire zu haben.)

Noch etwas komplizierter wird es, wenn wir die Schleife in eine Funktion einbetten:

for_seq <- function(IVs) {
   models <- vector("list", length(IVs))
   for (i in seq_along(IVs)) {
     IV <- IVs[i]
     models[[i]] <- reg(IV)
   }
   models
}

Die Base R-Funktion mit der for-Schleife ist (in Verbindung mit print) geeignet, Ergebnisse in der Konsole auszugeben. Sie liefert jedoch per se kein Ergebnis zurück! Daher legen wir hier eine Liste an, die bereits auf die benötigte Länge vordefiniert ist. (Mit einer leeren Liste zu starten, die in jedem Schleifendurchlauf um ein Element verlängert wird, wäre sehr ineffizient, da jedes Mal eine neue Speicherzuweisung erfolgt.) In der Schleife wird dann das jeweilige Regressionsmodell mit dem Schleifenindex i in die Liste geschrieben. Am Ende müssen wir die gesamte Liste nennen – return(models) wäre expliziter.

Schleifen mit foreach

Die Syntax für foreach-Schleifen ist etwas anders:

library(foreach)

foreach (n = IVs) %do%
   reg(n)

Diese Schleife läuft sequentiell: Sie nutzt den sog. Adapter %do%. Ein Vorteil der foreach-Variante gegenüber Base R zeigt sich bereits ohne Parallelisierung, wenn wir die Schleife in eine Funktion einbetten. foreach kümmert sich automatisch um ein Rückgabe-Objekt, der Code wird dadurch wesentlich kürzer. (Voreinstellung: Ergebnis wird als Liste zurückgegeben – damit bin ich in diesem Fall zufrieden. Mit dem .combine-Argument kann man auch andere Datentypen oder Funktionen angeben. Mit „c“ erhält man einen Vektor.)

foreach_seq <- function(IVs) {
   foreach (n = IVs) %do%
     reg(n)
}

foreach_seq(IVs)

So erhalten wir wieder die Zusammenfassungen der 200 Regressionsmodelle – doch unser Ziel war es ja, diese Berechnung zu parallelisieren.

Parallelisierung der foreach-Schleife

Zunächst geben wir an, welche Arbeiter wir nutzen wollen. Auf einem einzelnen PC können wir dazu die detectCores()-Funktion nutzen, um die Anzahl der Prozessorkerne zu ermitteln. In Produktivumgebungen, etwa serverbasierten R-Installationen, ist davon abzuraten. (Es könnten mehrere gleichzeitig laufende R-Skripte jeweils auf alle oder fast alle Kerne zugreifen wollen … Eine Alternative bietet parallely::availableWorkers(), das Umgebungsvariablen berücksichtigen kann, mit denen Admins die verfügbaren Kerne steuern können.)

library(parallel)
cl <- makeCluster(detectCores() - 1)
cl

Um foreach zu parallelisieren, ändern wir den Adapter von %do% zu %dopar%. Das können wir mit dem doParallel-Paket tun:

library(doParallel)
registerDoParallel(cl)

foreach (n = IVs) %dopar%
   reg(n)

Eine kleine Herausforderung gibt es noch, wenn wir die Schleife wieder in eine Funktion einbetten wollen:

foreach_par <- function(IVs) {
   foreach (n = IVs) %dopar%
     reg(n)
}

foreach_par(IVs)

Fehler in reg(n) : task 1 failed - "konnte Funktion "reg" nicht finden"

Die Arbeiter beginnen in einer leeren R-Session (Environment) – wir müssen ihnen sowohl die Regressionsfunktion als auch die Daten ausdrücklich zur Verfügung stellen:

clusterExport(cl, c("reg", "data"))

foreach_par(IVs)

Danach läuft die Schleife per Funktionsaufruf parallel.

Benchmarks: Geschwindigkeits-Gewinn durch Parallelisierung mit foreach

Um wie viel schneller wird die Berechnung der 200 Modelle per Schleife, wenn wir sie parallelisieren? Es empfiehlt sich, dazu auf ein spezialisiertes Paket zurückzugreifen. Einzelne Messungen mit system.time() sind im Vergleich recht ungenau und können zudem je nach weiteren Hintergrundprozessen schwanken.

library(bench)
 times <- bench::mark(
   for_seq(IVs),
   foreach_seq(IVs),
   foreach_par(IVs),
   iterations = 5
)

times

ggplot2::autoplot(times)

Auf meiner Maschine mit 11 Arbeitern (6 Kerne, 12 Arbeiter durch Hyperthreading, einen habe ich dem Betriebssystem vorbehalten) läuft foreach sequentiell etwas langsamer als die Base R-for-Schleife – durch die Parallelisierung wird foreach allerdings etwa doppelt so schnell wie for:

foreach sequentiell und parallel im Vergleich zu einer Base-R-for-Schleife. Benchmarking mit dem bench-Paket (Tabelle)

Eine Stärke des bench-Pakets ist der explizite Umgang mit dem garbage collector (der „Müllabfuhr“, die von Zeit zu Zeit den Speicher bereinigt).

foreach sequentiell und parallel im Vergleich zu einer Base-R-for-Schleife. Benchmarking mit dem bench-Paket (grafisch mit autoplot())

Die jeweils fünf Messungen pro Funktion sind recht homogen, die Rangfolge der drei Funktionen deutlich: die parallelisierte Variante gewinnt klar. Die x-Achse (Zeit) ist logarithmiert.

Freue mich über Kommentare!