Code-Performanz in R: R-Code beschleunigen
Dies ist der zweite Teil unserer Serie über Code-Performanz in R. Hier behandeln wir verschiedene Ansätze, um R-Code zu beschleunigen. Zum einen ist dieses Wissen bereits nützlich, bevor man anfängt, neuen Code zu schreiben; zum anderen hilft es dabei, bestehenden Code zu beschleunigen.
Wenn Sie bestehenden Code optimieren wollen, aber noch gar nicht wissen, welcher Teil davon eigentlich am meisten Zeit in Anspruch nimmt, empfehlen wir den ersten Teil unserer Serie über die Messung der Code-Performanz.
Dort wird auch das Paket microbenchmark
vorgestellt, das wir im Folgenden verwenden, um die Laufzeit zu messen. Die erste Regel ist zwar offensichtlich, aber deswegen noch lange nicht immer leicht zu befolgen.
Nicht denselben Code mehrfach laufen lassen
Wenn man mit Schleifen arbeitet, kann es vorkommen, dass ein Teil des Codes in jedem Schleifendurchlauf exakt dasselbe tut, weil er gar nicht von der Schleifen-Variable abhängt. In diesem Fall kann man den Berechnungsschritt einmalig vor der Schleife ausführen und danach das Ergebnis in jedem Schleifendurchlauf verwenden. Das gilt ebenso für apply
-Strukturen.
Im folgenden Beispiel wird ein Datensatz nach einer bestimmten Bedingung gefiltert - zu Beginn eines lapply
( filterInside
). Da diese Filterbedingung aber immer identisch bleibt, kann auch vor dem lapply
gefiltert werden (filterBefore
):
microbenchmark(
"filterInside" = {
# Für alle Spezies aus dem Iris-Datensatz...:
lapply(X = unique(iris$Species), function(spec) {
# Filter: nur Fälle mit Sepal.Length > 5
dat <- iris[iris$Sepal.Length > 5, ]
# Mittlere Sepal.Width für diese Spezies berechnen
mean(dat$Sepal.Width[dat$Species == spec])
})
},
"filterBefore" = {
# Filter vor dem lapply:
dat <- iris[iris$Sepal.Length > 5, ]
lapply(X = unique(iris$Species), function(spec) {
mean(dat$Sepal.Width[dat$Species == spec])
})
}
)
## Unit: microseconds
## expr min lq mean median uq max neval
## filterInside 379.898 403.1045 490.9101 424.4410 472.4715 3594.538 100
## filterBefore 258.282 275.5905 324.4593 280.6355 318.4795 2240.069 100
In der ersten Version wird der Filter innerhalb jeder Spezies separat angewendet. In der zweiten Version wird der komplette Datensatz nur einmal gefiltert, was deutlich schneller ist. Bei größeren Datensätzen oder mehr Ausprägungen als nur drei Iris-Spezies kann sich das enorm auswirken.
Die beschriebene Situation tritt sehr oft bei der Datenvorbereitung auf, nicht nur beim Filtern, sondern beispielsweise auch bei Umwandlungen wie as.numeric
oder as.character
.
Nicht an vorhandene Objekte anhängen
Folgende Situation: Wir wollen mehrere Werte berechnen und in einem Vektor speichern. Wir wissen auch schon, wie viele Werte wir berechnen wollen, d.h. wir wissen, wie lang der Vektor sein wird. Nun gibt es zwei mögliche Vorgehensweisen:
Entweder startet man mit einem leeren Vektor (Länge 0) und hängt jedes Ergebnis an, oder man erstellt zunächst einen Vektor voller NA-Werte und ersetzt diese Stück für Stück. Wie die Überschrift schon erahnen lässt, ist das zweite Vorgehen schneller:
microbenchmark(
"append" = {
# Leeren Vektor mit Länge 0 erstellen
x <- c()
# 1000 mal eine Zufallszahl anhängen
for (i in 1:1000) x <- c(x, rnorm(1))
},
"fill" = {
# Vektor mit 1000 NAs erstellen
x <- rep(NA, 1000)
# Jede Stelle mit einer Zufallszahl ersetzen
for (i in 1:1000) x[i] <- rnorm(1)
}
)
## Unit: milliseconds
## expr min lq mean median uq max neval
## append 3.799159 4.192788 4.829870 4.410594 4.736862 9.440320 100
## fill 2.869444 3.172670 3.742984 3.367564 3.715332 9.182746 100
Warum macht das eigentlich einen Unterschied? Die Antwort hat damit zu tun, wie R intern funktioniert. Wenn man einen neuen Wert an das Objekt anhängt, muss R jedes Mal eine Kopie des alten Objekts machen und dafür Arbeitsspeicher reservieren. Das benötigt eine gewisse Zeit. Wenn man direkt zu Beginn den kompletten Vektor der Länge 1000 erstellt, muss nicht 1000 Mal eine Kopie erstellt werden.
Ähnlich sieht es aus, wenn man einen String nach und nach mit paste
zusammenbaut. Stattdessen kann man zuerst alle Einzelteile des Strings erstellen und dann in einem einzigen Schritt zusammenfügen. Im folgenden Beispiel erstellen wir einen String, der - mit Komma getrennt - die ersten zehn Buchstaben des Alphabets enthält.
microbenchmark(
"append" = {
# Character mit erstem Buchstaben (a) al Startpunkt erstellen
x <- letters[1]
# Jeden Buchstaben einzeln anhängen
for (i in letters[2:10]) x <- paste(x, i, sep = ", ")
},
"collapse" = paste(letters[1:10], collapse = ", ")
)
## Unit: microseconds
## expr min lq mean median uq max neval
## append 1411.662 1552.267 1860.54328 1706.0015 1991.200 5227.634 100
## collapse 3.439 4.061 6.51417 5.8275 7.111 36.650 100
Hier sehen wir einen enormen Unterschied: Das Anhängen braucht fast 300 Mal so lang wie das einmalige Zusammenfügen!
Vektorisierung
Im obigen Beispiel mit den 1000 Zufallszahlen hätte es sogar noch eine schnellere Möglichkeit gegeben: Vektorisierung. Statt in einer Schleife jede Stelle des Vektors separat abzuhandeln, lässt sich damit alles auf einmal erledigen. Natürlich kommt dabei intern immer noch an irgendeiner Stelle eine Schleife vor, doch diese internen Schleifen sind in C implementiert, sodass sie viel schneller sind als Schleifen in R. Diesen Vorteil sollte man immer nutzen, wenn es irgendwie möglich ist. Im Folgenden wird dem obigen Beispiel eine dritte, vektorisierte Version hinzugefügt:
microbenchmark(
"append" = {
x <- c()
for (i in 1:1000) x <- c(x, rnorm(1))
},
"fill" = {
x <- rep(NA, 1000)
for (i in 1:1000) x[i] <- rnorm(1)
},
"vectorize" = rnorm(1000)
)
## Unit: microseconds
## expr min lq mean median uq max neval
## append 3877.761 4349.151 5396.08739 4842.093 5401.3660 12775.512 100
## fill 2838.814 3262.778 4645.08529 3676.416 4344.2275 69052.746 100
## vectorize 51.645 54.592 62.66464 56.123 61.3565 137.767 100
Die Vektorisierung ist mit Abstand am schnellsten! Dies ist zwar nur ein sehr simples Beispiel und Vektorisierung ist nicht immer so offensichtlich - und manchmal auch gar nicht möglich.
Weitere Möglichkeiten zur Vektorisierung bieten die Funktionen rowSums
, rowMeans
, colSums
und colMeans
, die zeilen- bzw. spaltenweise Mittelwert/Summe eines Matrix-ähnlichen Objekts (z.B. Dataframe
) berechnen. Diese sind auch intern vektorisiert und damit deutlich schneller als ein selbst geschriebener lapply
.
C++ verwenden
Falls keine vektorisierte Funktion zur Verfügung steht, kann man ausgewählte Code-Abschnitte auch selbst in C++ überführen. Um diese Teile nahtlos in den R-Code einzufügen, ist das Rcpp
-Paket hilfreich. Dies kann zum Beispiel bei langsamen Schleifen sinnvoll sein und ist in der Regel genau so schnell wie Vektorisierung. Man braucht dazu natürlich gewisse C++-Kenntnisse, aber Basiswissen reicht völlig aus.
Zwischenergebnisse speichern
Einfach aber nützlich: Zwischenergebnisse lassen sich mittels save()
als Rdata-Datei speichern. Dies lohnt sich beispielsweise oft für aufbereitete Daten oder die Ergebnisse einer zeitaufwändigen Modellierung. Auf diese Weise kann man am nächsten Tag damit weiterarbeiten, ohne die Berechnungen erneut auszuführen.