data.table vs. dplyr und dtplyr: Benchmarks

data-table_dplyr_logos

Zwei der populärsten Pakete zur Datenaufbereitung in R sind data.table (Matt Dowle, Arun Srinivasan, viele Mitarbeiter) und dplyr (Hadley Wickham, viele Mitarbeiter). Während data.table zu Recht den Ruf hat, sehr schnell zu sein, hat dplyr vielen den Einstieg in R enorm erleichtert.

Geschwindigkeitsvergleiche: data.table vs. dplyr – beachte dtplyr!

Es gibt bereits seit Jahren eine Reihe von Benchmarks / Geschwindigkeitsvergleichen in Blogartikeln (z. B. hier, hier, hier, hier, hier, hier oder hier) sowie etliche Einführungsvideos zu beiden Paketen.

Video zum Beitrag: data.table vs. dplyr, dtplyr: Benchmarks / Geschwindigkeits-Vergleiche

Was mich heute zu einem weiteren Artikel motiviert: das dtplyr-Paket, das eine Brücke von dplyr zu data.table schlägt. Heißt konkret: Man kann das „Beste aus beiden Welten“ genießen – die von vielen als intuitiv empfundene dplyr-Syntax und die Geschwindigkeit von data.table. dtplyr übersetzt intern dplyr-Code in data.table-Code.

dtplyr wurde 2019 komplett umgeschrieben und ist seitdem wesentlich attraktiver, da leistungsfähiger. Der „Trick“ bestand darin, dplyr-Code nicht mehr Zeile für Zeile sofort zu übersetzen (eager evaluation), sondern einen Codeblock im ganzen anzunehmen und zu übersetzen, was deutliches Optimierungspotenzial mit sich bringt (lazy evaluation). Sichtbares Zeichen dafür ist die lazy_dt()-Funktion in den folgenden Codeblöcken.

TL; DR: Kurzfassung der wichtigsten Ergebnisse

Wem der Artikel zu lang ist: Es zeigt sich, dass man mit dtplyr sehr nahe an die data.table-Geschwindigkeit herankommt, auch wenn man selbst dplyr-Code schreibt. Entscheidend ist dabei, mit welchem Objekttyp man die Befehlskette beginnt: dtplyr ist sehr schnell, wenn man es mit einem data.table-Objekt füttert, und wesentlich langsamer, wenn man mit einem tibble (data.frame im tidyverse-Format) beginnt.

Inspiration: Iyar Lin

Die Inspiration zu diesem Beitrag stammt von Iyar Lins Artikel dtplyr speed benchmarks – vielen Dank an ihn (mehr von ihm auf seinem Github-Profil). Sein Beitrag wurde Ende Mai 2020 veröffentlicht, kurz bevor die neue dplyr-Version 1.0 auf CRAN zugänglich gemacht wurde. Ich habe Lins Code in mehrerlei Hinsicht umgeschrieben:

  • microbenchmark() für die Geschwindigkeitsvergleiche statt system.time().
    Nach meiner Erfahrung können Messungen von Codeabschnitten erheblich schwanken (z. B. abhängig von weiteren Betriebssystemprozessen, die im Hintergrund ablaufen). Lin verwendete zwei Messungen pro Funktion, ich übernehme microbenchmarks Voreinstellung von 10 Durchläufen, die Verteilungen zeigen.
  • Ein Skript statt Verteilung auf mehrere Skripte (den Code findet Ihr hier auf meinem Github-Profil)
  • Definition eigener Funktionen statt Einbettung des gesamten Codes in den Benchmark-Aufruf
  • Beschränkung auf zwei statt fünf Aufgaben; sie erlauben bereits hilfreiche Rückschlüsse

Verwendete R-Pakete

Folgende R-Pakete kamen zum Einsatz:

library(data.table)
library(dplyr)
library(dtplyr)
library(microbenchmark)     # Benchmarks
library(ggplot2)            # für die Autoplot-Funktion von microbenchmark

Datensimulation

Folgende Daten werden verwendet:

N <- 1e7
K <- 100
set.seed(1)

DT <- data.table(
  id1 = sample(sprintf("id%03d", 1:K), N, TRUE), # large groups (char)
  id5 = sample(N / K, N, TRUE), # small groups (int)
  v1 = sample(5, N, TRUE), # int in range [1,5]
  v2 = sample(5, N, TRUE), # int in range [1,5]
  v3 = sample(round(runif(100, max = 100), 4), N, TRUE) # numeric, e. g. 23.5749
)

DF <- as_tibble(DT)
  • 10 Millionen Zeilen
  • zwei ID-Variablen: id1 mit 100 Gruppen, id2 mit 100.000
  • drei numerische Variablen: v1 und v2 mit einem Wertebereich von 1 bis 5; v3 mit Zufallszahlen mit Dezimalstellen zwischen 0 und 100
  • Zwei Varianten: DT als data.table-Objekt und DF als tibble (data.frame im tidyverse-Stil)

Bei den IDs und den Aufgaben habe ich die Nummerierung von Lins Artikel beibehalten, um die Zuordnung zu erleichtern, wenn jemand vergleichen möchte: Aufgaben 1 (q1) und 5 (q5).

Aufgaben: Nach IDs gruppieren, Summen berechnen

Folgende Aufgaben sollen von den Code-Alternativen gelöst werden:

  • Nach id1 gruppieren, Summe von v1 berechnen: Ergebnis mit 100 Zeilen
  • Nach id5 gruppieren, Summen von v1 bis v3 berechnen: Ergebnis mit 100.000 Zeilen

Codevarianten: data.table, dtplyr (2 Varianten), dplyr

Für die Geschwindigkeitsvergleiche (Benchmarks) gehen vier Code-Varianten an den Start:

  • Eine reine data.table-Variante: DT_q1() und DT_q5(),
  • Eine dtplyr-Variante, die mit einem tibble beginnt: dtplyr_q1() und dtplyr_q5(),
  • Eine dtplyr-Variante, die mit einem data.table beginnt: dt_dtplyr_q1() und dt_dtplyr(q5),
  • Eine reine dplyr-Variante: dplyr_q1() und dplyr_q5().

Hier der Code dazu – es werden zunächst „nur“ Funktionen definiert, die später elegant in den Benchmarks aufgerufen werden:

# data.table
DT_q1 <- function() {
  DT[, sum(v1), keyby = id1]
}

DT_q5 <- function() {
  DT[, lapply(.SD, sum), keyby = id5, .SDcols = 3:5]
}
# dtplyr: Beginn mit tibble

dtplyr_q1 <- function() { DF %>%
  lazy_dt() %>%
  group_by(id1) %>%
  summarise(sum(v1)) %>%
  as_tibble()
}

dtplyr_q5 <- function() { DF %>%
  lazy_dt() %>%
  group_by(id5) %>%
  # summarise(across(v1:v3), sum) %>% # does not work yet, need old version
  summarise_at(vars(v1:v3), sum) %>%
  as_tibble()
}
# dtplyr: Beginn mit data.table

dt_dtplyr_q1 <- function() { DT %>%
  lazy_dt() %>%
  group_by(id1) %>%
  summarise(sum(v1)) %>%
  as.data.table()
}

dt_dtplyr_q5 <- function() { DT %>%
  lazy_dt() %>%
  group_by(id5) %>%
  # summarise(across(v1:v3), sum) %>% # does not work yet, need old version
  summarise_at(vars(v1:v3), sum) %>%
  as.data.table()
}
# Reiner dplyr-Code

dplyr_q1 <- function() { DF %>%
  group_by(id1) %>%
  summarise(sum(v1)) %>%
  as_tibble()
}

dplyr_q5 <- function() { DF %>%
  group_by(id5) %>%
  summarise_at(vars(v1:v3), sum) %>%
  # summarise(across(v1:v3), sum) %>% # extremely slow on my machine!!
  as_tibble()
}

Benchmarking: microbenchmark()

Für die Geschwindigkeits-Vergleiche nutze ich das microbenchmark-Paket mit der gleichnamigen Funktion. Gegenüber der Base-R-Variante system.time() bietet es den Vorteil, auf einfache Weise (als Funktionsparameter) wiederholte Aufrufe durchzuführen. In der Praxis zeigen sich oft enorme Schwankungen zwischen den Messungen, mit zum Teil erheblichen Ausreißern. Man sollte sich also nie auf eine einzelne Messung verlassen, sondern lieber eine Verteilung von Messwerten betrachten.

Eine neuere Variante ist das bench-Paket aus dem RStudio-Umfeld (Jim Hester), das automatisiert testen kann, ob die Ergebnisse der zu vergleichenden Funktionen übereinstimmen. Darauf verzichte ich hier, da wir es mit unterschiedlichen Objekttypen zu tun haben: data.table vs. tibble (beides Erweiterungen / Varianten von data.frames). Stattdessen überprüfe ich selbst, ob die Funktionen dieselben Ergebnisse liefern.

# Funktionen testen
str(DT_q1())
str(dtplyr_q1())
str(dt_dtplyr_q1())
str(dplyr_q1())

str(DT_q5())
str(dtplyr_q5())
str(dt_dtplyr_q5())
str(dplyr_q5())

Das Benchmarking ist nun sehr einfach:

q1 <- microbenchmark(
  DT_q1(),
  dtplyr_q1(),
  dt_dtplyr_q1(),
  dplyr_q1(),
  times = 10
)

q5 <- microbenchmark(
  DT_q5(),
  dtplyr_q5(),
  dt_dtplyr_q5(),
  dplyr_q5(),
  times = 10
)

Die Ergebnisse lassen sich sowohl tabellarisch als auch grafisch darstellen.

Ergebnisse: Grafisch und tabellarisch

Violinplot: Funktion q1 (100 Ergebniszeilen) im Vergleich; erstellt mit autoplot(q1)

Für q1 illustriert das Violinplot (Ergebnis der simplen autoplot()-Funktion) die Bandbreite der Ergebnisse; insbesondere die dtplyr-Varianten schwanken recht deutlich.

Aufgabe q1: Numerische Ergebnisse aus dem microbenchmark-Paket.

Betrachten wir die tabellarische Zusammenfassung, ergibt sich folgendes Bild:

  • cld steht für compact letter display und stellt sehr kompakt Signifikanztests dar. Demnach sind data.table (DT_q1) und dtplyr mit einem data.table-Objekt (dt_dtplyr_q1) signifikant schneller (Gruppe a) als dplyr (dplyr_q1) und dtplyr mit einem tibble (dtplyr_q1) (Gruppe b).
  • Um Ausreißern weniger Gewicht zu verleihen, bietet sich der Median als Entscheidungsgrundlage an. Demnach gewinnt (wenig überraschend) data.table, allerdings dicht gefolgt von der dtplyr-Variante, die auf ein data.table-Objekt angewendet wird. Die reine dplyr-Variante und dtplyr mit einem tibble sind deutlich abgeschlagen.

Hier noch das Boxplot, etwas unelegant als Base R-Diagramm; man beachte die logarithmische y-Achse:

Aufgabe q1: Boxplot aus dem microbenchmark-Paket, erstellt mit boxplot(q1)

Kommen wir zur zweiten Aufgabe (q5) mit 100.000 Ergebniszeilen:

Violinplot: Funktion q5 (100.000 Ergebniszeilen) im Vergleich; erstellt mit autoplot(q5)

Hier ist die reine dplyr-Variante weit abgeschlagen! Das bestätigt Befunde, die ich andernorts las – data.table hat insbesondere bei größeren Datenmengen Vorteile gegenüber dplyr. Ein wesentlicher Aspekt dabei dürfte die Speichereffizienz sein. R verwendet intern häufig sog. copy-on-modify-Operationen, d. h. dass Objekte kopiert und die Kopien bearbeitet werden. data.table hat Methoden implementiert, die dieses Verfahren umgehen und speicherintensive (und damit auch zeitaufwändige) Kopien vermeiden.

Aufgabe q5: Numerische Ergebnisse aus dem microbenchmark-Paket.

Der Signifikanztest bestätigt: Während die anderen drei Code-Varianten sich auf vergleichbarem Niveau bewegen (wobei dtplyr langsamer wird, wenn es mit einem tibble starten muss), benötigt dplyr mehr als fünf mal so lang für die gleiche Aufgabe.

Aufgabe q5: Boxplot aus dem microbenchmark-Paket, erstellt mit boxplot(q5)

Fazit: dtplyr für dplyr-Fans, die schnelleren Code benötigen

Das dtplyr-Paket erweist sich als wertvolle Unterstützung für diejenigen, die im tidyverse / dplyr-Stil schreiben wollen, aber bessere Performance benötigen. Wichtig: Die Befehlskette für dtplyr sollte mit einem data.table-Objekt beginnen!

Allerdings sollte man beachten: dtplyr wird sich schwerer tun mit Aufgaben, für die es keine genaue dplyr-Entsprechung gibt. Das gilt vor allem für cross joins und rolling joins. Siehe https://github.com/tidyverse/dtplyr

Zudem sollte man vorsichtig sein mit Generalisierungen. Je nach Aufgabe können die Vergleiche recht unterschiedlich ausfallen, wobei man mit dplyr kaum data.table schlagen wird.

Hier mein R-Code: https://github.com/fjodor/data.table_dplyr_dtplyr

In meinem Fortgeschrittenenkurs gibt es ein ganzes Kapitel zu Strategien, R-Code zu optimieren. Dabei geht es zunächst um sequentiellen R-Code (R nutzt in aller Regel nur einen Prozessorkern), um Strategien und Anwendungsmöglichkeiten, R-Code zu parallelisieren, sowie um einen Ausblick auf Pakete, um R mit großen Datenmengen zu nutzen, die die Grenzen des Arbeitsspeichers sprengen. Auch zu Strategien der Datenbankanbindung mit R kann ich Inhalte anbieten (z. B. Berechnungen in der Datenbank ausführen, Daten aus Datenbanken visualisieren, Paket-Empfehlungen).

Zu dplyr vergleiche den Beitrag R-Programmierung: Was ist %>% ? dplyr vs. Base R.

Literaturempfehlungen für effizienten R-Code:

Roger Peng (links) ist ausgewiesener Statistik- und R-Experte, betreibt unter anderem einen Podcast mit Hillary Parker und hat mehrere Bücher veröffentlicht. Das Buch kann man auch hier lesen.

Freue mich über Kommentare!