R-Code parallelisieren mit parallel::clusterApply()

Pexels: Chait Goli

R-Code ist oft schnell zu schreiben, aber nicht immer schnell genug in der Ausführung. Eine Methode, dem abzuhelfen, besteht darin, R-Code zu parallelisieren, d. h. mehrere Prozessorkerne oder mehrere Arbeiter einzusetzen. Das parallel-Paket, das zur Base-R-Installation gehört, bietet mit der clusterApply()-Funktion eine elegante Möglichkeit.

Parallelisierung: Vorgehen und Vorbereitung

Ziel ist es, 200 Regressionsmodelle mit jeweils einer anderen unabhängigen Variable (Prädiktor) aufzustellen. Zunächst laden wir die benötigten Pakete:

library(parallel)
library(bench)
library(tidyverse)

Mit bench erstellen wir Geschwindigkeitsvergleiche (benchmarks). Warum ich inzwischen das neuere bench gegenüber dem älteren microbenchmark vorziehe, habe ich im vorigen Beitrag erläutert: Doubletten ausschließen in R: unique() und wie man es schneller macht. Das tidyverse benötigen wir für die grafische Darstellung der Benchmarks sowie für die map-Funktion aus dem purrr-Paket.

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

Mit makeCluster() werden die Arbeiter vorbereitet. Ich könnte hier eine hart codierte Zahl in den Code schreiben. Da ich mit verschiedenen Rechnern arbeite (Desktop-PC, Laptops), die über unterschiedlich viele Prozessorkerne verfügen, bevorzuge ich die detectCores()-Funktion. Üblicherweise lässt man einen Kern für das Betriebssystem frei, daher „-1“. (Ausnahme: Wenn ein Rechner nur über zwei Kerne verfügt, würde ich beide nutzen, sonst gibt es keine Parallelisierung.)

In meinem Fall sind es 12 Arbeiter, von denen ich im Folgenden 11 nutze. Meine 6-Kern-Maschine (AMD Ryzen 5) ermöglicht Hyperthreading, d. h. sie spielt dem Betriebssystem die doppelte Anzahl an Kernen vor. Mit dem Parameter (logical = FALSE) könnte ich das Hyperthreading in makeCluster() ausschalten und nur die Anzahl der physischen Kerne nutzen.

Hinweis: In Produktivumgebungen (etwa serverbasierten R-Installationen) wird von detectCores() abgeraten: Es könnten auch andere Prozesse auf das System zugreifen. Eine Alternative bietet parallely::availableWorkers() (früher im future-Paket beheimatet, Ende 2020 ins parallely-Paket ausgelagert). availableWorkers() kann Umgebungsvariablen (environment variables) berücksichtigen, mit denen z. B. Administratoren die Anzahl der freigegebenen Kerne begrenzen können.

Folgende Daten wollen wir nutzen – sie werden für dieses Beispiel simuliert, können aber leicht durch andere Daten ersetzt werden:

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)])

Die Daten umfassen 90 Zeilen, eine Zielvariable result sowie 200 unabhängige Variablen, die von der replicate-Funktion schlicht von X1 bis X200 bezeichnet wurden. IVs (für independent variables) ist ein Vektor mit den 200 Variablennamen, die in die Regressionsmodelle eingehen sollen.

Eine benutzerdefinierte Funktion für die Regressionsmodelle

Ziel ist es hier, die summaries (Zusammenfassungen) der Regressionsmodelle zu erhalten. Das leistet eine benutzerdefinierte Funktion, die eine unabhängige Variable als Eingabe-Parameter annimmt und das summary zurückgibt:

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

Die Modellformel ist ein eigener Objekttyp in R: formula. Der Text aus der gleichbleibenden abhängigen Variable result und dem Prädiktor muss daher explizit in eine Formel umgewandelt werden mit as.formula(). Nun können wir die Funktion testen:

reg("X1")

IV <- "X1"
reg(IV)

Mann kann einen Prädiktor direkt als String (character, Text) übergeben oder als Variable. Alle 200 Modelle aufzustellen geht nun mit einem simplen Einzeiler, per lapply:

lapply(IVs, reg)

Ergebnis: Eine Liste mit 200 Regressionsmodellen – je eines pro Prädiktor.

lapply parallelisieren: clusterApply()

Diesen lapply-Aufruf zu parallelisieren ist nun denkbar einfach: Wir ersetzen lapply() durch clusterApply(). Dabei gibt es einen zusätzlichen Parameter: Die Arbeiter, hier im Objekt cl (für cluster) hinterlegt.

clusterApply(cl, IVs, reg)

Doch es gibt ein kleines Problem:

Fehler in checkForRemoteErrors(val) : 
   200 nodes produced errors; first error: 'data' must be a data.frame, environment, or list

Die Arbeiter starten mit einer leeren R-Umgebung (environment). Deshalb finden sie die Daten nicht – wir müssen sie ihnen explizit zur Verfügung stellen. Das erreichen wir mit der clusterExport()-Funktion:

clusterExport(cl, "data")
clusterApply(cl, IVs, reg)

Anschließend läuft auch clusterApply() klaglos durch und liefert wieder die 200 Regressionsmodelle zurück – diesmal unter Nutzung mehrerer Prozessorkerne.

Geschwindigkeitsvergleich: Sequentieller vs. parallelisierter R-Code

Die linearen Regressionsmodelle erfordern keinen besonders hohen Rechenaufwand, sodass man auf modernen Computern kaum eine Verzögerung in diesem Anwendungsfall sieht. Mit dem bench-Paket wollen wir nun nachmessen, wie sich die Parallelisierung auswirkt. Ich habe noch eine map-Funktion aus dem purrr-Paket zum Vergleich aufgenommen. Die map-Funktionen sind typ-stabiler als die apply-Funktionen in Base R und im funktionalen Programmieren daher etwas sicherer; zudem sind Parameter einheitlicher benannt, sodass der Wechsel von einer map-Funktion zur anderen leichter fällt als der Wechsel von einer apply-Funktion zu anderen.

times <- bench::mark(
   lapply(IVs, reg),
   map(IVs, reg),
   clusterApply(cl, IVs, reg)
 )
 
times
plot(times)

map läuft auf meinem Rechner minimal langsamer als lapplyclusterApply() mit 11 Arbeitern ist dagegen rund 3x schneller. D. h. man erhält einen deutlichen Geschwindigkeitsgewinn durch die Parallelisierung – man kann jedoch nicht naiv die Laufzeit durch die Anzahl der Arbeiter teilen. Es gibt einen gewissen Zusatzaufwand für die Kommunikation zwischen Master und Workers. Dennoch lohnt die Parallelisierung häufig – sobald der Rechenaufwand diesen Overhead übersteigt, was meist der Fall sein dürfte.

Benchmarks: lapply, map und clusterApply im Vergleich

Stark, wie bench::mark() die garbage collection sichtbar macht. Die Parallelisierung profitiert von weniger garbage-collector-Aufrufen. Für Benchmarks empfiehlt sich auch deshalb ein spezialisiertes Paket wie bench, weil man dadurch mehrere Durchläufe simulieren kann. Eine einzelne Messung ist oft nicht zuverlässig, da es erhebliche Schwankungen geben kann.

Welche Erfahrungen habt Ihr mit Laufzeiten von R-Code gemacht? Viel Erfolg mit Euren Projekten!