2021-05-14
Effizientes SQL auf Pandas mit DuckDB
Mark Raasveldt, Hannes Mühleisen
Kürzlich erschien ein Artikel, der für SQL in der Datenanalyse wirbt. Im DuckDB-Team sind wir große Fans von SQL. Es ist eine vielseitige Sprache, mit der sich viele Datenumwandlungen effizient ausführen lassen, ohne dass man sich um die physische Darstellung der Daten oder um den optimalen Ausführungsweg kümmern muss.
Aggregationen und Transformationen lassen sich sehr gut in einem externen Datenbanksystem wie Postgres ausführen, wenn die Daten dort liegen. Irgendwann müssen die Daten aber wieder nach Pandas und NumPy. Diese Bibliotheken sind der Standard für den Datenaustausch im großen Ökosystem der Data-Science-Bibliotheken in Python1, etwa scikit-learn oder TensorFlow.
1Apache Arrow gewinnt in diesem Bereich ebenfalls stark an Bedeutung, und DuckDB spricht auch Arrow.
Wenn Sie aus einer Datei lesen (z. B. CSV oder Parquet), landen die Daten oft gar nicht erst in einem externen Datenbanksystem, sondern direkt in einem Pandas-DataFrame.
SQL auf Pandas
Nachdem die Daten in einem Pandas-DataFrame liegen, sind oft noch weitere Aufbereitung und Analyse nötig. SQL ist ein sehr mächtiges Werkzeug für solche Transformationen. Mit DuckDB lassen sich SQL-Abfragen effizient direkt auf Pandas-DataFrames ausführen.
Als kurzer Vorgeschmack: Dieses Snippet führt beliebige SQL-Abfragen direkt auf Pandas-DataFrames mit DuckDB aus.
# to install: pip install duckdbimport pandas as pdimport duckdb
mydf = pd.DataFrame({'a' : [1, 2, 3]})print(duckdb.query("SELECT sum(a) FROM mydf").to_df())Im Rest des Artikels gehen wir genauer darauf ein, wie das funktioniert und wie schnell es ist.
Datenintegration und SQL auf Pandas
Eines der Kernziele von DuckDB ist, dass der Zugriff auf gängige Formate einfach sein soll. DuckDB kann Abfragen parallel direkt auf einem Pandas-DataFrame ausführen (oder auf einer Parquet-/CSV-Datei oder einer Arrow-Tabelle …). Ein eigener (zeitaufwendiger) Importschritt ist nicht nötig.
DuckDB kann Abfrageergebnisse auch direkt in jedes dieser Formate schreiben. Sie können DuckDB nutzen, um einen Pandas-DataFrame parallel per SQL zu verarbeiten und das Ergebnis wieder in einen Pandas-DataFrame zu wandeln, um es in anderen Data-Science-Bibliotheken weiterzuverwenden.
Führt man eine SQL-Abfrage aus, sucht DuckDB nach Python-Variablen, deren Name zu den Tabellennamen in der Abfrage passt, und liest automatisch die Pandas-DataFrames. Im vorherigen Beispiel sieht man das in Aktion:
import pandas as pdimport duckdb
mydf = pd.DataFrame({'a' : [1, 2, 3]})print(duckdb.query("SELECT sum(a) FROM mydf").to_df())Der SQL-Tabellenname mydf wird als lokale Python-Variable mydf interpretiert, die zufällig ein Pandas-DataFrame ist, den DuckDB direkt lesen und abfragen kann. Spaltennamen und Typen werden automatisch aus dem DataFrame übernommen.
Das ist nicht nur bequem, sondern auch sehr effizient. Für viele Abfragen können Sie DuckDB nutzen, um Daten schneller als Pandas und mit deutlich geringerem Speicherverbrauch zu verarbeiten, ohne das binäre Pandas-DataFrame-Format zu verlassen („Pandas-in, Pandas-out“). Anders als bei einem externen Datenbanksystem wie Postgres ist die Übertragungszeit von Eingabe und Ausgabe vernachlässigbar (siehe Anhang A).
Leistung von SQL auf Pandas
Um die Leistung von DuckDB bei SQL auf Pandas-DataFrames zu zeigen, stellen wir einige Benchmarks vor. Der Quellcode ist interaktiv in Google Colab verfügbar. In diesen Benchmarks arbeiten wir ausschließlich auf Pandas-DataFrames. Sowohl der DuckDB- als auch der Pandas-Code läuft vollständig nach dem Prinzip „Pandas-in, Pandas-out“.
Benchmark-Aufbau und Datensatz
Wir führen den Benchmark vollständig in der Google-Colab-Umgebung aus. Als Datensatz nutzen wir den bekannten TPC-H-Datensatz. Konkret konzentrieren wir uns auf die Tabellen lineitem und orders, die größten Tabellen im Benchmark. Der gesamte Datensatz ist etwa 1 GB im unkomprimierten CSV-Format („Scale Factor“ 1).
Da DuckDB mehrere Prozessoren nutzen kann (Multithreading), nehmen wir eine einthreadige Variante und eine Variante mit zwei Threads auf. DuckDB skaliert weit über zwei Threads hinaus, Google Colab unterstützt jedoch nur zwei.
Setup
Zuerst müssen wir DuckDB installieren. Das ist eine einfache Zeile.
pip install duckdbFür die Verarbeitung laden wir zwei Parquet-Dateien mit wget. Anschließend laden wir die Daten mit dem eingebauten Parquet-Reader von DuckDB in einen Pandas-DataFrame. Das System erkennt automatisch, dass wir eine Parquet-Datei lesen, anhand der Endung .parquet.
lineitem = duckdb.query( "SELECT * FROM 'lineitemsf1.snappy.parquet'").to_df()orders = duckdb.query( "SELECT * FROM 'orders.parquet'").to_df()Ungroupierte Aggregationen
Für unsere erste Abfrage führen wir eine Reihe ungroupierter Aggregationen über den Pandas-DataFrame aus. Hier die SQL-Abfrage:
SELECT sum(l_extendedprice), min(l_extendedprice), max(l_extendedprice), avg(l_extendedprice)FROM lineitem;Der Pandas-Code sieht ähnlich aus:
lineitem.agg( Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))| Name | Time (s) |
|---|---|
| DuckDB (1 Thread) | 0.079 |
| DuckDB (2 Threads) | 0.048 |
| Pandas | 0.070 |
Dieser Benchmark ist eine sehr einfache Abfrage, und Pandas schneidet hier gut ab. Bei solchen einfachen Abfragen glänzt Pandas, weil es direkt die Numpy-Routinen für diese Aggregationen aufrufen kann, die sehr effizient sind. Dennoch liegt DuckDB im einthreadigen Fall auf Augenhöhe mit Pandas und profitiert bei Bedarf von Multithreading.
Gruppierte Aggregation
Für die zweite Abfrage führen wir dieselben Aggregationen aus, diesmal mit einer Gruppierungsbedingung. In SQL fügen wir dafür eine GROUP-BY-Klausel hinzu.
SELECT l_returnflag, l_linestatus, sum(l_extendedprice), min(l_extendedprice), max(l_extendedprice), avg(l_extendedprice)FROM lineitemGROUP BY l_returnflag, l_linestatus;In Pandas nutzen wir vor der Aggregation die Funktion groupby.
lineitem.groupby( ['l_returnflag', 'l_linestatus']).agg( Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))| Name | Time (s) |
|---|---|
| DuckDB (1 Thread) | 0.43 |
| DuckDB (2 Threads) | 0.32 |
| Pandas | 0.84 |
Diese Abfrage ist schon komplexer, und obwohl Pandas anständig arbeitet, ist es etwa doppelt so langsam wie die einthreadige DuckDB-Variante. DuckDB hat eine stark optimierte Aggregate-Hash-Tabelle, die Gruppierung und Berechnung aller Aggregationen in einem Durchlauf über die Daten erledigt.
Gruppierte Aggregation mit Filter
Angenommen, wir wollen nicht über alle Daten aggregieren, sondern nur über eine Teilmenge. Das geht mit einem Filter, der uninteressante Tupel entfernt. In SQL erledigen wir das über die WHERE-Klausel.
SELECT l_returnflag, l_linestatus, sum(l_extendedprice), min(l_extendedprice), max(l_extendedprice), avg(l_extendedprice)FROM lineitemWHERE l_shipdate <= DATE '1998-09-02'GROUP BY l_returnflag, l_linestatus;In Pandas können wir eine gefilterte Variante des DataFrame mit Auswahlklammern erzeugen.
# filter out the rowsfiltered_df = lineitem[ lineitem['l_shipdate'] < "1998-09-02"]# perform the aggregateresult = filtered_df.groupby( ['l_returnflag', 'l_linestatus']).agg( Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))In DuckDB kombiniert der Query-Optimizer Filter und Aggregation zu einem Durchlauf über die Daten und liest nur relevante Spalten. In Pandas haben wir dieses Glück nicht. Der Filter reduziert die gesamte lineitem-Tabelle, einschließlich aller Spalten, die wir nicht nutzen. Dadurch ist die Filteroperation deutlich teurer als nötig.
Wir können diese Optimierung („Projection Pushdown“ in der Datenbankliteratur) manuell nachbauen. Dazu wählen wir zuerst nur die für die Abfrage relevanten Spalten und filtern dann den DataFrame. Das ergibt folgendes Snippet:
# projection pushdownpushed_down_df = lineitem[ ['l_shipdate', 'l_returnflag', 'l_linestatus', 'l_extendedprice']]# perform the filterfiltered_df = pushed_down_df[ pushed_down_df['l_shipdate'] < "1998-09-02"]# perform the aggregateresult = filtered_df.groupby( ['l_returnflag', 'l_linestatus']).agg( Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))| Name | Time (s) |
|---|---|
| DuckDB (1 Thread) | 0.60 |
| DuckDB (2 Threads) | 0.42 |
| Pandas | 3.57 |
| Pandas (manual pushdown) | 2.23 |
Der manuelle Projection Pushdown beschleunigt die Abfrage in Pandas deutlich, aber die gefilterte Aggregation bleibt teuer. Für einen Filter schreibt Pandas eine Kopie des gesamten DataFrame (minus der ausgefilterten Zeilen) zurück in den Speicher. Das kostet Zeit, wenn der Filter nicht sehr selektiv ist.
Dank holistischem Query-Optimizer und effizienter Query-Engine schneidet DuckDB bei dieser Abfrage deutlich besser ab.
Joins
Für die letzte Abfrage joinen wir (merge in Pandas) die Tabelle lineitem mit orders und filtern auf den interessierenden Auftragsstatus. Das führt zu folgender SQL-Abfrage:
SELECT l_returnflag, l_linestatus, sum(l_extendedprice), min(l_extendedprice), max(l_extendedprice), avg(l_extendedprice)FROM lineitemJOIN orders ON (l_orderkey = o_orderkey)WHERE l_shipdate <= DATE '1998-09-02' AND o_orderstatus='O'GROUP BY l_returnflag, l_linestatus;Für Pandas müssen wir einen merge-Schritt ergänzen. Im einfachen Ansatz mergen wir lineitem und orders, wenden dann die Filter an und schließlich Gruppierung und Aggregation. Das ergibt folgendes Snippet:
# perform the joinmerged = lineitem.merge( orders, left_on='l_orderkey', right_on='o_orderkey')# filter out the rowsfiltered_a = merged[ merged['l_shipdate'] < "1998-09-02"]filtered_b = filtered_a[ filtered_a['o_orderstatus'] == "O"]# perform the aggregateresult = filtered_b.groupby( ['l_returnflag', 'l_linestatus']).agg( Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))Hier verpassen wir zwei Leistungshebel:
- Erstens mergen wir viel zu viele Spalten, weil Spalten dabei sind, die für den Rest der Abfrage nicht nötig sind (Projection Pushdown).
- Zweitens mergen wir viel zu viele Zeilen. Wir können die Filter vor dem Merge anwenden, um die zu mergende Datenmenge zu reduzieren (Filter Pushdown).
Diese beiden Optimierungen manuell angewendet ergeben folgendes Snippet:
# projection & filter on lineitem tablelineitem_projected = lineitem[ ['l_shipdate', 'l_orderkey', 'l_linestatus', 'l_returnflag', 'l_extendedprice']]lineitem_filtered = lineitem_projected[ lineitem_projected['l_shipdate'] < "1998-09-02"]# projection and filter on order tableorders_projected = orders[ ['o_orderkey', 'o_orderstatus']]orders_filtered = orders_projected[ orders_projected['o_orderstatus'] == 'O']# perform the joinmerged = lineitem_filtered.merge( orders_filtered, left_on='l_orderkey', right_on='o_orderkey')# perform the aggregateresult = merged.groupby( ['l_returnflag', 'l_linestatus']).agg( Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))Beide Optimierungen wendet DuckDBs Query-Optimizer automatisch an.
| Name | Time (s) |
|---|---|
| DuckDB (1 Thread) | 1.05 |
| DuckDB (2 Threads) | 0.53 |
| Pandas | 15.2 |
| Pandas (manual pushdown) | 3.78 |
Der naive Ansatz ist im Vergleich zur optimierten Variante extrem zeitaufwendig. Das zeigt den Nutzen des automatischen Query-Optimizers. Selbst nach der Optimierung ist der Pandas-Code deutlich langsamer als DuckDB, weil Zwischenergebnisse nach den einzelnen Filtern und Joins im Speicher gehalten werden.
Fazit
Mit DuckDB nutzen Sie die ausdrucksstarke SQL-Sprache, ohne Daten in Pandas hinein- und wieder herausschieben zu müssen. DuckDB ist sehr einfach zu installieren und bietet viele Vorteile: Query-Optimizer, automatisches Multithreading und Verarbeitung größerer Datenmengen als der Hauptspeicher. DuckDB nutzt den Postgres-SQL-Parser und bietet viele der gleichen SQL-Funktionen wie Postgres, einschließlich fortgeschrittener Features wie Window-Funktionen, korrelierte Subqueries, (rekursive) Common Table Expressions, verschachtelte Typen und Sampling. Fehlt Ihnen etwas, öffnen Sie bitte ein Issue.
Anhang A: Hin und zurück: Daten von Pandas in eine SQL-Engine und zurück
Klassische SQL-Engines nutzen das Client-Server-Paradigma: Ein Client verbindet sich über einen Socket mit einem Server. Abfragen laufen auf dem Server, Ergebnisse kommen danach zum Client. Dasselbe gilt etwa für Postgres aus Python. Leider ist diese Übertragung ein ernstes Nadelöhr. In-Process-Engines wie SQLite oder DuckDB haben dieses Problem nicht.
Um zu zeigen, wie teuer die Übertragung über einen Socket ist, haben wir einen Benchmark mit Postgres, SQLite und DuckDB ausgeführt. Der Quellcode liegt auf GitHub.
In diesem Benchmark kopieren wir einen (recht kleinen) Pandas-DataFrame mit 10 Millionen 4-Byte-Integern (40 MB) von Python nach PostgreSQL, SQLite und DuckDB. Da das Standard-to_sql von Pandas eher langsam war, haben wir eine eigene Optimierung ergänzt: Pandas schreibt den DataFrame in eine temporäre CSV-Datei, und PostgreSQL kopiert die Daten direkt aus dieser Datei in eine neu erzeugte Tabelle. Das funktioniert natürlich nur, wenn der Datenbankserver auf derselben Maschine wie Python läuft.
| Name | Time (s) |
|---|---|
| Pandas to Postgres using to_sql | 111.25 |
| Pandas to Postgres using temporary CSV file | 5.57 |
| Pandas to SQLite using to_sql | 6.80 |
| Pandas to DuckDB | 0.03 |
SQLite ist hier deutlich besser als Postgres, aber immer noch eher langsam. Das liegt daran, dass to_sql in Pandas eine große Zahl von INSERT INTO-Statements ausführt, wobei alle einzelnen Werte des Pandas-DataFrame in eine zeilenweise Darstellung von Python-Objekten umgewandelt und an das System übergeben werden. DuckDB liest dagegen direkt das zugrunde liegende Array aus Pandas, wodurch die Operation fast instantan ist.
Das Zurückholen von Abfrageergebnissen oder Tabellen aus dem SQL-System nach Pandas ist ein weiteres mögliches Nadelöhr. Das eingebaute read_sql_query ist extrem langsam, und selbst der optimiertere CSV-Weg braucht für diesen winzigen Datensatz mindestens eine Sekunde. DuckDB erledigt auch diese Umwandlung fast instantan.
| Name | Time (s) |
|---|---|
| PostgreSQL to Pandas using read_sql_query | 7.08 |
| PostgreSQL to Pandas using temporary CSV file | 1.29 |
| SQLite to Pandas using read_sql_query | 5.20 |
| DuckDB to Pandas | 0.04 |
Anhang B: Vergleich mit PandaSQL
Es gibt ein Paket namens PandaSQL, das ebenfalls SQL direkt auf Pandas ausführt. Es baut jedoch auf der to_sql- und from_sql-Infrastruktur auf, die wir in Anhang A als extrem langsam gesehen haben.
Zur Sicherheit haben wir die erste ungroupierte Aggregationsabfrage in PandaSQL gestoppt. Beim ersten Versuch auf dem Originaldatensatz kam es zu einem Out-of-Memory-Fehler, der unsere Colab-Sitzung beendet hat. Deshalb haben wir den Benchmark für PandaSQL mit einer Stichprobe von 10 % der Originalgröße (600.000 Zeilen) wiederholt. Hier die Ergebnisse:
| Name | Time (s) |
|---|---|
| DuckDB (1 Thread) | 0.023 |
| DuckDB (2 Threads) | 0.014 |
| Pandas | 0.017 |
| PandaSQL | 24.43 |
PandaSQL (angetrieben von SQLite) ist bei diesem einfachen Benchmark etwa 1000× langsamer als Pandas oder DuckDB. Der Leistungsunterschied war so groß, dass wir die übrigen Benchmarks für PandaSQL nicht ausgeführt haben.
Anhang C: Abfrage direkt auf Parquet
In den Benchmarks oben haben wir die Parquet-Dateien vollständig nach Pandas gelesen. DuckDB kann Abfragen aber auch direkt auf Parquet-Dateien ausführen (parallel!). In diesem Anhang zeigen wir die Leistung im Vergleich zum vorherigen Laden nach Python.
Für den Benchmark führen wir zwei Abfragen aus: die einfachste (ungroupierte Aggregation) und die komplexeste (der letzte Join) und vergleichen die Kosten der direkten Ausführung auf der Parquet-Datei mit dem Laden nach Pandas über read_parquet.
Setup
In DuckDB können wir eine View über die Parquet-Datei mit folgender Abfrage anlegen. Damit können wir Abfragen über die Parquet-Datei ausführen, als wäre sie eine normale Tabelle. Um Projection Pushdown müssen wir uns nicht kümmern: Ein SELECT * reicht, DuckDBs Optimizer projiziert zur Laufzeit nur die benötigten Spalten.
CREATE VIEW lineitem_parquet AS SELECT * FROM 'lineitemsf1.snappy.parquet';CREATE VIEW orders_parquet AS SELECT * FROM 'orders.parquet';Ungroupierte Aggregation
Nach dem Anlegen dieser View können wir dieselben Abfragen wie zuvor ausführen, diesmal gegen die Tabelle lineitem_parquet.
SELECT sum(l_extendedprice), min(l_extendedprice), max(l_extendedprice), avg(l_extendedprice) FROM lineitem_parquet;Für Pandas müssen wir zuerst read_parquet ausführen, um die Daten nach Pandas zu laden. Dazu nutzen wir den von Apache Arrow angetriebenen Parquet-Reader. Danach können wir die Abfrage wie zuvor ausführen.
lineitem_pandas_parquet = pd.read_parquet('lineitemsf1.snappy.parquet')result = lineitem_pandas_parquet.agg(Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))Wieder das Problem: Pandas liest die Parquet-Datei vollständig. Um das zu umgehen, müssen wir Projection Pushdown erneut manuell ausführen, indem wir read_parquet die gewünschten Spalten mitgeben.
Der Optimizer in DuckDB erkennt das selbst anhand der ausgeführten Abfrage.
lineitem_pandas_parquet = pd.read_parquet('lineitemsf1.snappy.parquet', columns=['l_extendedprice'])result = lineitem_pandas_parquet.agg(Sum=('l_extendedprice', 'sum'), Min=('l_extendedprice', 'min'), Max=('l_extendedprice', 'max'), Avg=('l_extendedprice', 'mean'))| Name | Time (s) |
|---|---|
| DuckDB (1 Thread) | 0.16 |
| DuckDB (2 Threads) | 0.14 |
| Pandas | 7.87 |
| Pandas (manual pushdown) | 0.17 |
Der Leistungsunterschied zwischen Pushdown und keinem Pushdown ist dramatisch. Mit Pushdown liegt Pandas in derselben Größenordnung wie DuckDB. Ohne Pushdown wird die gesamte Datei von der Platte geladen, einschließlich der anderen 15 Spalten, die für die Abfrage nicht nötig sind.
Joins
Nun zur letzten Abfrage aus dem Join-Abschnitt. Zur Erinnerung:
SELECT l_returnflag, l_linestatus, sum(l_extendedprice), min(l_extendedprice), max(l_extendedprice), avg(l_extendedprice)FROM lineitemJOIN orders ON (l_orderkey = o_orderkey)WHERE l_shipdate <= DATE '1998-09-02' AND o_orderstatus='O'GROUP BY l_returnflag, l_linestatus;Für Pandas erzeugen wir wieder zwei Versionen: eine naive und eine manuell optimierte. Den genauen Code finden Sie in Google Colab.
| Name | Time (s) |
|---|---|
| DuckDB (1 Thread) | 1.04 |
| DuckDB (2 Threads) | 0.89 |
| Pandas | 20.4 |
| Pandas (manual pushdown) | 3.95 |
Bei dieser komplexeren Abfrage verschwindet der kleine Leistungsunterschied zwischen Pandas-DataFrame und Parquet-Datei, und die DuckDB-Zeiten ähneln stark denen von zuvor. Das zusätzliche Lesen von Parquet macht manuelle Optimierungen am Pandas-Code noch nötiger, die bei SQL in DuckDB gar nicht erforderlich sind.