Blog

Code-Performanz in R: R-Code beschleunigen

05.05.2021 10:30
von

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.

Weitere Teile der Artikelserie über Code-Performanz in R:

Zurück