2024-04-02

duckplyr: dplyr, angetrieben von DuckDB

Hannes Mühleisen

Die duckplyr-Dokumentation finden Sie unter duckplyr.tidyverse.org.

Hintergrund

Tabellarische Daten in eine für die Analyse geeignete Form zu bringen kann eine Herausforderung sein. Irgendwie entsteht jeder Datensatz anders. Unterschiede zwischen Datensätzen liegen in der logischen Organisation der Information in Zeilen und Spalten oder in spezifischeren Entscheidungen wie der Darstellung von Daten, Währungen, kategorialen Werten, fehlenden Daten usw. Die Aufgabe wird nicht einfacher durch fehlenden globalen Konsens in trivialen Fragen wie dem Dezimaltrennzeichen. Um neue Erkenntnisse zu gewinnen, müssen wir oft Informationen aus mehreren Quellen kombinieren, zum Beispiel durch Join zweier Datensätze über einen gemeinsamen Identifikator. Es gibt aber einige wiederkehrende Operationen, die sich beim Umformen von Daten für die Analyse als universell nützlich erwiesen haben. Zum Beispiel beschreibt die Structured (English) Query Language, oder kurz SQL („See-Quel“), eine Menge gemeinsamer Operationen auf tabellarischen Daten wie Selektion, Projektion, Joins, Aggregation, Sortierung, Windowing und mehr. SQL erwies sich als riesiger Erfolg: Trotz seiner vielen Macken und vieler Versuche, es zu ersetzen, ist es immer noch die De-facto-Sprache für Datentransformation mit einer gigantischen Industrie dahinter.

library("DBI")
con <- dbConnect(...)
df <- dbGetQuery(con, "SELECT something, very, complicated FROM some_table JOIN another_table BY (some_shared_attribute) GROUP BY group_one, group_two ORDER BY some_column, and_another_column;")

Ein nicht sehr ergonomischer Weg, Daten nach R zu holen

Für Datenanalysten in interaktiven Programmierumgebungen wie R oder Python, möglicherweise in IDEs wie RStudio oder Jupyter Notebooks, war SQL zum Umformen von Daten nie wirklich eine natürliche Wahl. Klar, manchmal war SQL nötig, um Daten aus operativen Systemen zu holen, wie oben gezeigt, aber wenn sie die Wahl hatten, nutzten Analysten lieber die ergonomischeren Datenumformungsfunktionen dieser Sprachen. R hatte von Anfang an eingebaute Datenbearbeitung als Teil der Sprache mit der Klasse data.frame zur Darstellung tabellarischer Daten. 2014 definierte Hadley Wickham die logische Struktur tabellarischer Daten für sogenanntes „tidy“ Data und veröffentlichte die erste Version des Pakets dplyr, das die zuvor unhandlichen R-Befehle zum Umformen von Daten in eine einheitliche, konsistente API zusammenführen und vereinfachen sollte. In der Python-Welt erweiterte das weit verbreitete Pandas-Projekt Python um eine De-facto-Darstellung tabellarischer Daten samt relationaler Operatoren, allerdings ohne jeden Versuch von „Tidiness“.

Irgendwann begannen die Datenverarbeitungseinrichtungen von R und Python unter dem stetig wachsenden Gewicht der Datensätze zu knarren, die Menschen analysieren wollten. Datensätze wuchsen schnell in Millionen von Zeilen. Einer der frühen Datensätze, der besondere Behandlung brauchte, war zum Beispiel der American Community Survey, weil es einfach so viele Amerikaner gibt. Werkzeuge wie Pandas und dplyr waren aber auf Bequemlichkeit ausgelegt, nicht unbedingt auf Effizienz. Zum Beispiel fehlt ihnen die Fähigkeit, Datenumformungsjobs auf den inzwischen üblichen Mehrkernprozessoren zu parallelisieren.

Und obwohl es eine ganze Reihe aufkommender „Big Data“-Werkzeuge gab, erwies sich deren Nutzung aus einer interaktiven Datenanalyseumgebung als schlechte Entwicklererfahrung, zum Beispiel durch mehrsekündige Job-Startzeiten und sehr komplexe Setup-Verfahren weit jenseits der Fähigkeiten der meisten Datenanalysten. Die Welt der relationalen Datenmanagementsysteme hatte indes nicht stillgestanden. Große Fortschritte hatten die Effizienz analytischer Datenanalyse aus SQL verbessert: Innovationen rund um spaltenorientierte Datendarstellung, effiziente Query-Interpretation oder sogar Kompilation und automatische effiziente Parallelisierung steigerten die Query-Verarbeitungseffizienz um mehrere Größenordnungen. Bedauerlicherweise fanden diese Innovationen – selbst nach Jahrzehnten – nicht den Weg ins Werkzeug der Datenanalysten, wegen mangelnder Kommunikation zwischen Communities und der Versiegelung von Innovationen in korporaten, kommerziellen und Closed-Source-Produkten.

Es gibt zwei mögliche Wege aus diesem unglücklichen Szenario:

  1. die Datenanalysefähigkeiten von R und Python verbessern, damit sie größere Datensätze durch allgemeine Effizienzverbesserungen, Optimierung und Parallelisierung bewältigen können;
  2. bestehende State-of-the-Art-Technologie irgendwie in interaktive Datenanalyseumgebungen integrieren.

Das Hauptproblem bei Ansatz eins ist, dass der Bau einer wettbewerbsfähigen analytischen Query Engine von Grund auf ein Millionen-Dollar-Aufwand ist, der ein Team hochspezialisierter Experten für Query-Engine-Konstruktion erfordert. Es gibt viele bewegliche, hochkomplexe Teile, die alle gut zusammenpassen müssen. Es gibt scheinbar offensichtliche Fragen in Query Engines, für deren Lösung man einen Doktortitel in Datenmanagementsystemen bekommen kann. Eine so massive Investition in einem Raum wieder hereinzuholen, in dem Werkzeuge oft von Freiwilligen in der Freizeit gebaut und kostenlos veröffentlicht werden, ist eine Herausforderung. Es gibt aber ein paar anerkennenswerte Projekte in diesem Bereich wie data.table oder neuer pola.rs, die gegenüber älteren Werkzeugen deutlich bessere Performance bieten.

Ansatz zwei ist ebenfalls nicht ohne Herausforderungen: State-of-the-Art-Query-Engine-Technologie steckt oft hinter inkompatiblen Architekturen. Zum Beispiel ist die Zwei-Schichten-Architektur, in der ein Datenmanagementsystem auf einem eigenen Datenbankserver läuft und Client-Anwendungen über ein Client-Protokoll mit diesem Server interagieren, mit interaktiver Analyse eher unverträglich. Einen eigenen Datenbank-„Server“ einzurichten und zu warten – selbst auf demselben Rechner – ist immer noch schmerzhaft. Daten zwischen Analyseumgebung und Datenbankserver hin- und herzuschieben hat sich als ziemlich teuer erwiesen. Unglücklicherweise beeinflussen diese Architekturentscheidungen die Trade-offs der Query Engine tief und sind daher danach schwer zu ändern.

In diesem Bereich hat sich aber etwas bewegt: Eines der erklärten Ziele von DuckDB ist es, State-of-the-Art-analytische Datenmanagementtechnologie mit seiner In-Process-Architektur von der Systemarchitektur zu befreien. Einfach gesagt: Es gibt keinen eigenen Datenbankserver, und DuckDB läuft stattdessen in einem „Host“-Prozess. Dieser Host kann jede Anwendung sein, die Datenmanagementfähigkeiten braucht, oder einfach eine interaktive Datenanalyseumgebung wie Python oder R. Im Host zu laufen hat einen weiteren massiven Vorteil: Daten zwischen Host und DuckDB hin- und herzuschieben ist sehr günstig. Für R und Python kann DuckDB komplexe Abfragen direkt auf Data Frames in der Analyseumgebung ausführen, ohne Import- oder Konvertierungsschritte. Umgekehrt können DuckDBs Abfrageergebnisse direkt in Data Frames umgewandelt werden, was den Overhead der Integration mit nachgelagerten Bibliotheken für Plotting, weitere Analyse oder Machine Learning stark senkt. DuckDB kann beliebig komplexe relationale Abfragen effizient ausführen, einschließlich rekursiver und korrelierter Abfragen. DuckDB kann Larger-than-Memory-Datensätze sowohl beim Lesen und Schreiben als auch bei großen Zwischenergebnissen bewältigen, zum Beispiel aus Aggregationen mit Millionen Gruppen. DuckDB hat einen ausgefeilten Full-Query-Optimizer, der die zuvor üblichen manuellen Optimierungsschritte überflüssig macht. DuckDB bietet auch Persistenz: Tabellarische Daten werden in Dateien auf der Festplatte gespeichert. Die Tabellen in diesen Dateien können auch geändert werden – bei transaktionaler Integrität. Das sind in der interaktiven Datenanalyse unerhörte Features, das Ergebnis von Jahrzehnten Forschung und Engineering in analytischen Datensystemen.

Ein Problem bleibt: DuckDB spricht SQL. SQL ist eine beliebte Sprache, aber nicht alle Analysten wollen ihre Datentransformationen in SQL ausdrücken. Ein Hauptproblem ist, dass Abfragen typischerweise als Strings in R- oder Python-Skripten stehen und auf undurchsichtige Weise an ein Datenbanksystem geschickt werden. Das bedeutet All-or-Nothing-Semantik, und Probleme zu debuggen kann schwierig sein („You have an error in your SQL syntax; check the manual…“). APIs wie dplyr sind oft bequemer für den Nutzer; sie erlauben einer IDE Dinge wie Autovervollständigung von Funktionen, Variablennamen usw. Außerdem erlaubt die additive Natur der dplyr-API, eine Sequenz von Datentransformationen in kleinen Schritten aufzubauen, was die kognitive Last des Analysten gegenüber einer hundertzeiligen SQL-Abfrage erheblich senkt. Es gab einige frühe experimentelle Versuche, Rs native Data-Frame-API zu überladen, um auf SQL-Datenbanken abzubilden, aber diese Ansätze erwiesen sich als zu begrenzt in der Allgemeinheit, überraschend für Nutzer und generell zu brüchig. Es braucht einen besseren Ansatz.

Das R-Paket duckplyr

Um diese Probleme anzugehen, haben wir uns mit dem dplyr-Projektteam bei Posit (ehemals RStudio) und cynkra zusammengetan, um duckplyr zu entwickeln. duckplyr ist ein Drop-in-Ersatz für dplyr, angetrieben von DuckDB für Performance. Duckplyr setzt mehrere Innovationen im Bereich interaktiver Analyse um. Zuerst ist duckplyr zu installieren so einfach wie dplyr. DuckDB ist für R als eigenständiges R-Paket paketiert, das den gesamten Datenmanagementsystem-Code sowie Wrapper für R enthält. Sowohl das DuckDB-R-Paket als auch duckplyr sind auf CRAN verfügbar, sodass die Installation auf allen großen Plattformen unkompliziert ist:

install.packages("duckplyr")

Verbs

Unter der Haube übersetzt duckplyr die irgendwie-relationalen dplyr-Operationen („Verbs“) auf DuckDBs relationale Query-Verarbeitungsengine. Abgesehen von etwas Namensverwirrung gibt es eine weitgehend direkte Abbildung zwischen dplyrs Verbs wie select, filter, summarise usw. und DuckDBs Operatoren Project, Filter und Aggregate. Ein entscheidender Unterschied zu früheren Ansätzen: duckplyr geht nicht über DuckDBs SQL-Schnittstelle, um Query-Pläne zu erzeugen. Stattdessen nutzt duckplyr DuckDBs sogenannte „relationale“ API, um logische Query-Pläne direkt zu konstruieren. Diese API umgeht den SQL-Parser vollständig und reduziert die Schwierigkeit beim Escapen von Operatoren, Identifikatoren, Konstanten und Tabellennamen erheblich, die andere Ansätze wie dbplyr plagt.

Wir haben die C++-relationale API nach R exponiert, sodass DuckDB-Query-Pläne direkt aus R konstruiert werden können. Diese Low-Level-API ist nicht für die direkte Nutzung gedacht, sondern wird von duckplyr genutzt, um die dplyr-Verbs auf die relationale DuckDB-API und damit auf Query-Pläne abzubilden. Hier ein Beispiel:

library("duckplyr")
as_duckplyr_df(data.frame(n=1:10)) |>
mutate(m=n+1) |>
filter (m > 5) |>
count() |>
explain()
┌───────────────────────────┐
│ PROJECTION │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ n │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ UNGROUPED_AGGREGATE │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ count_star() │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ FILTER │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│(+(CAST(n AS DOUBLE), 1.0) │
│ > 5.0) │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ EC: 10 │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ R_DATAFRAME_SCAN │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ data.frame │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ n │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ EC: 10 │
└───────────────────────────┘

Wir sehen, wie eine Sequenz von dplyr-Verbs mutate, filter und count „magisch“ in einen DuckDB-Query-Plan aus Scan, Filter, Projektionen und einer Aggregation verwandelt wird. Ganz unten wird ein Operator R_DATAFRAME_SCAN hinzugefügt. Dieser Operator liest einen R-Data-Frame direkt, als wäre er eine Tabelle in DuckDB, ohne tatsächlichen Datenimport. Das neue Verb explain() lässt DuckDBs logischen Query-Plan ausgeben, sodass wir sehen können, was DuckDB auf Basis der duckplyr-Verb-Sequenz ausführen will.

Ausdrücke

Eine oft übersehene, aber entscheidende Komponente von Datentransformationen sind sogenannte Ausdrücke. Ausdrücke sind (konzeptionell) skalare Transformationen von Konstanten und Spalten aus den Daten, die zum Beispiel abgeleitete Spalten erzeugen oder Spaltenwerte in boolesche Werte für Filter umwandeln können. Man könnte zum Beispiel einen Ausdruck wie (amount - discount) * tax schreiben, um den tatsächlich in Rechnung gestellten Betrag zu berechnen, ohne dass dieser Betrag in einer Spalte gespeichert ist, oder einen Ausdruck wie value > 42 in einem Filter, um alle Zeilen zu entfernen, in denen der Wert kleiner oder gleich 42 ist. Dplyr stützt sich auf die Basis-R-Engine, um Ausdrücke auszuwerten, mit einigen kleinen Änderungen, um Variablennamen auf Spalten in den Eingabedaten aufzulösen. Beim Verschieben der Ausdrucksauswertung nach DuckDB wird der Prozess etwas aufwendiger. DuckDB hat ein eigenes, unabhängiges Ausdruckssystem aus einem eingebauten Satz von Funktionen (z. B. min), Skalarwerten und Typen. Um R-Ausdrücke in DuckDB-Ausdrücke zu verwandeln, nutzen wir ein interessantes R-Feature, um nicht ausgewertete abstrakte Syntaxbäume aus Funktionsargumenten einzufangen. Durch Traversieren des Baums können wir R-Skalare in DuckDB-Skalare, R-Funktionsaufrufe in DuckDB-Funktionsaufrufe und R-Variablenreferenzen in DuckDB-Spaltenreferenzen verwandeln. Es sollte klar sein, dass diese Transformation nicht perfekt sein kann: Es gibt Funktionen in R, die DuckDB schlicht nicht unterstützt, zum Beispiel solche aus der Unzahl beigetragener Pakete. Während wir den Satz unterstützter Ausdrücke erweitern, wird es immer welche geben, die nicht übersetzt werden können. Bei nicht übersetzbaren Ausdrücken können wir dem Nutzer aber trotzdem ein Ergebnis liefern. Dazu haben wir einen transparenten Fallback-Mechanismus implementiert, der die bestehende R-Ausdrucksauswertung nutzt, wenn ein Ausdruck nicht in DuckDBs Ausdruckssprache übersetzt werden kann. Die folgende Transformation m = n + 1 kann zum Beispiel übersetzt werden:

as_duckplyr_df(data.frame(n=1:10)) |>
mutate(m=n+1) |>
explain()
┌───────────────────────────┐
│ PROJECTION │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ n │
│ m │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ R_DATAFRAME_SCAN │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ data.frame │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ n │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ EC: 10 │
└───────────────────────────┘

Die folgende Transformation mit einer Inline-Lambda-Funktion dagegen (noch) nicht:

as_duckplyr_df(data.frame(n=1:10)) |>
mutate(m=(\(x) x+1)(n)) |>
explain()
┌───────────────────────────┐
│ R_DATAFRAME_SCAN │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ data.frame │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ n │
│ m │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ EC: 10 │
└───────────────────────────┘

Es ist etwas schwer zu sehen (und wir arbeiten daran, das zu verbessern): Der explain()-Output unterscheidet sich klar zwischen den beiden mutate-Ausdrücken. Im ersten Fall berechnet DuckDB das + 1 als Teil des Projektionsoperators, im zweiten Fall schlug die Übersetzung fehl und ein Fallback wurde genutzt, sodass die Berechnung in der R-Engine stattfand. Der Vorteil des automatischen Fallbacks ist, dass Dinge „einfach funktionieren“. Der Nachteil ist meist ein Performance-Einbruch durch den Fallback, zum Beispiel durch fehlende automatische Parallelisierung. Wir planen einen Debug-Modus, in dem Nutzer den Übersetzungsprozess inspizieren und Einblick bekommen, warum Übersetzungen fehlschlagen.

Eager vs. Lazy Materialisierung

Dplyr und Pandas folgen einer Ausführungsstrategie namens „Eager Materialization“. Jedes Mal, wenn eine Operation auf einem Data Frame aufgerufen wird, wird sie sofort ausgeführt und das Ergebnis im Speicher erzeugt. Das kann problematisch sein. Betrachten Sie folgendes Beispiel: Ein Datensatz mit zehn Millionen Zeilen wird geändert, indem 1 zu einer Spalte addiert wird. Dann wird die Operation top_n aufgerufen, um nur die ersten zehn Zeilen zu holen. Wegen Eager Materialization wird die Addition auf zehn Millionen Zeilen ausgeführt, das Ergebnis im Speicher erzeugt, nur damit fast alles sofort weggeworfen wird, weil nur die ersten zehn Zeilen angefordert wurden. Duckplyr löst dieses Problem mit einer Strategie namens „Lazy Materialization“, bei der zunächst keine Aktion ausgeführt, sondern die Absicht der Nutzer festgehalten wird. Das bedeutet, dass die Addition von eins zu zehn Millionen Zeilen nicht sofort ausgeführt wird. Das System kann die angeforderte Berechnung optimieren und die Addition nur auf den ersten wenigen Zeilen ausführen. Wichtig auch: Das Zwischenergebnis der Addition wird nie tatsächlich im Speicher erzeugt, was den Speicherdruck stark senkt.

Lazy Computation stellt aber ein mögliches Integrationsproblem dar: Das Ergebnis einer Lazy Computation muss eine Art Platzhalterobjekt für Lazy Computation sein, das an eine weitere Lazy-Operation übergeben oder zur Auswertung gezwungen werden kann, z. B. über eine spezielle Print-Methode. Das würde aber die Rückwärtskompatibilität mit dplyr brechen, wo das Ergebnis jeder dplyr-Operation selbst ein vollständig materialisierter Data Frame ist. Das bedeutet, dass diese Ergebnisse direkt an nachgelagerte Operationen wie Plotting übergeben werden können, ohne dass das Plotting-Paket von der „Lazyness“ des duckplyr-Ergebnisobjekts wissen muss. Um das zu lösen, haben wir kreativ ein R-Feature namens ALTREP genutzt. ALTREP erlaubt R-Objekten verschiedene In-Memory-Darstellungen und das Ausführen eigenen Codes, wann immer auf diese Objekte zugegriffen wird. Duckplyr-Ergebnisse sind ja Lazy-Platzhalterobjekte, erscheinen aber gleichzeitig als ganz normale R-Data-Frames. R-Data-Frames sind im Wesentlichen benannte Listen typisierter Vektoren mit einem speziellen Attribut row.names. Weil DuckDBs Lazy Query Planning die Namen und Typen der resultierenden Tabelle bereits kennt, können wir die Namen in den Lazy Data Frame exportieren. Wir kennen aber noch weder die Zeilenzahl noch den Inhalt. Deshalb machen wir sowohl die eigentlichen Datenvektoren als auch den row.names-Vektor, der die Data-Frame-Länge enthält, zu Lazy Vectors. Diese Vektoren tragen einen Callback, den die R-Engine aufruft, wann immer nachgelagerter Code – z. B. Plotting-Code – diese Vektoren berührt. Der Callback löst tatsächlich die Berechnung der gesamten Pipeline und die Transformation des Ergebnisses in einen R-Data-Frame aus. Duckplyrs eigene Operationen rühren diese Vektoren nicht an; sie arbeiten weiter lazy über ein spezielles Lazy-Computation-Objekt, das ebenfalls im Lazy Data Frame gespeichert ist. Diese Methode erlaubt duckplyr, gleichzeitig lazy und nicht lazy zu sein, was den vollständigen Drop-in-Ersatz für das eifrig ausgewertete dplyr ermöglicht und gleichzeitig die Lazy Evaluation hält, die entscheidend ist, damit DuckDB eine Full-Query-Optimierung der verschiedenen Transformationsschritte machen kann.

Hier ein Beispiel für die Dualität des Ergebnisses von duckplyr-Operationen mit Rs Methode inspect():

dd <- as_duckplyr_df(data.frame(n=1:10)) |> mutate(m=n+1)
.Internal(inspect(dd))
@12daad988 19 VECSXP g0c2 [OBJ,REF(2),ATT] (len=2, tl=0)
@13e0c9d60 13 INTSXP g0c0 [REF(4)] DUCKDB_ALTREP_REL_VECTOR n (INTEGER)
@13e0ca1c0 14 REALSXP g0c0 [REF(4)] DUCKDB_ALTREP_REL_VECTOR m (DOUBLE)
ATTRIB:
@12817a838 02 LISTSXP g0c0 [REF(1)]
TAG: @13d80d420 01 SYMSXP g1c0 [MARK,REF(65535),LCK,gp=0x4000] "names" (has value)
@12daada08 16 STRSXP g0c2 [REF(65535)] (len=2, tl=0)
@13d852ef0 09 CHARSXP g1c1 [MARK,REF(553),gp=0x61] [ASCII] [cached] "n"
@13e086338 09 CHARSXP g1c1 [MARK,REF(150),gp=0x61] [ASCII] [cached] "m"
TAG: @13d80d9d0 01 SYMSXP g1c0 [MARK,REF(56009),LCK,gp=0x4000] "class" (has value)
@12da9e208 16 STRSXP g0c2 [REF(65535)] (len=2, tl=0)
@11ff15708 09 CHARSXP g0c2 [MARK,REF(423),gp=0x60] [ASCII] [cached] "duckplyr_df"
@13d892308 09 CHARSXP g1c2 [MARK,REF(1513),gp=0x61,ATT] [ASCII] [cached] "data.frame"
TAG: @13d80d1f0 01 SYMSXP g1c0 [MARK,REF(65535),LCK,gp=0x4000] "row.names" (has value)
@13e0c9970 13 INTSXP g0c0 [REF(65535)] DUCKDB_ALTREP_REL_ROWNAMES

Wir sehen, dass die interne Struktur des Data Frame tatsächlich einen Data Frame widerspiegelt, aber wir sehen auch die speziellen Vektoren DUCKDB_ALTREP_REL_VECTOR, die die nicht ausgewerteten Datenvektoren verbergen, sowie DUCKDB_ALTREP_REL_ROWNAMES, die verbergen, dass die wahren Dimensionen des Data Frame noch nicht bekannt sind.

Benchmark: TPC-H Q1

Zum Abschluss eine kurze Demonstration der Performance-Verbesserungen von duckplyr. Wir nutzen den Datengenerator des bekannten TPC-H-Benchmarks, der hilfreicherweise als DuckDB-Extension verfügbar ist. Mit dem „Scale Factor“ 1 erzeugt der folgende DuckDB/R-Einzeiler einen Datensatz mit etwas über 6 Millionen Zeilen und speichert ihn im R-Data-Frame namens „lineitem“:

lineitem <- duckdb:::sql("INSTALL tpch; LOAD tpch; CALL dbgen(sf=1); FROM lineitem;")

Wir haben die TPC-H-Benchmark-Abfrage 1 von ihrer ursprünglichen SQL-Formulierung in dplyr-Syntax übersetzt:

tpch_01 <- function() {
lineitem |>
select(l_shipdate, l_returnflag, l_linestatus, l_quantity, l_extendedprice, l_discount, l_tax) |>
filter(l_shipdate <= !!as.Date("1998-09-02")) |>
select(l_returnflag, l_linestatus, l_quantity, l_extendedprice, l_discount, l_tax) |>
summarise(
sum_qty = sum(l_quantity),
sum_base_price = sum(l_extendedprice),
sum_disc_price = sum(l_extendedprice * (1 - l_discount)),
sum_charge = sum(l_extendedprice * (1 - l_discount) * (1 + l_tax)),
avg_qty = mean(l_quantity),
avg_price = mean(l_extendedprice),
avg_disc = mean(l_discount),
count_order = n(),
.by = c(l_returnflag, l_linestatus)
) |>
arrange(l_returnflag, l_linestatus)
}

Wir können diese Funktion jetzt mit dplyr und duckplyr ausführen und die Zeit zum Berechnen des Ergebnisses beobachten. „Stock“-dplyr braucht auf meinem MacBook für diese Abfrage ca. 400 Millisekunden, duckplyr nur ca. 70 Millisekunden. Diese Zeit umfasst wieder die ganze Magie, die Sequenz von dplyr-Verbs in einen relationalen Operatorbaum zu verwandeln, diesen Baum zu optimieren, den Eingabe-R-Data-Frame on-the-fly in ein DuckDB-Intermediate zu konvertieren und das (zugegeben kleine) Ergebnis zurück in einen R-Data-Frame zu verwandeln. Natürlich ist der hier genutzte Datensatz noch relativ klein und die Abfrage nicht besonders komplex, im Wesentlichen eine einzelne gruppierte Aggregation. Die Unterschiede werden bei komplexeren Transformationen auf größeren Datensätzen deutlich ausgeprägter. duckplyr kann auch direkt auf große Sammlungen von z. B. Parquet-Dateien auf dem Speicher zugreifen und Filter in diese Scans pushen, was die Performance ebenfalls stark verbessern kann.

Fazit

Das R-Paket duckplyr verpackt DuckDBs State-of-the-Art-Techniken zur analytischen Query-Verarbeitung in einer dplyr-kompatiblen API. Wir haben uns große Mühe gegeben, Kompatibilität zu sichern, obwohl wir das Ausführungsparadigma von eager auf lazy umgestellt und Ausdrücke in eine andere Umgebung übersetzen müssen. Wir arbeiten weiter daran, duckplyrs Fähigkeiten auszubauen, würden aber gerne Ihre Erfahrungen beim Ausprobieren hören.

Hier zwei Aufzeichnungen von der letztjährigen posit::conf, in denen wir DuckDB für R und duckplyr vorstellen: