2025-03-21
Maximale Delta-Scan-Performance in DuckDB
Sam Ansmink
Überblick
In unserem vorherigen Beitrag haben wir erklärt, worum es beim Tabellenformat Delta Lake geht und wie DuckDBs delta-Erweiterung die Bibliothek Delta Kernel nutzt, um native Unterstützung zu bieten. In diesem Blogbeitrag konzentrieren wir uns darauf, wie Sie beim Lesen von Delta-Tabellen aus DuckDB die beste Performance herausholen. Wir beginnen mit einer kurzen Wiederholung zu Delta, zeigen dann die Performance-Gewinne der letzten Releases und demonstrieren schließlich drei zentrale Features im neuesten Delta-Release, die sicherstellen, dass Sie das Maximum aus Ihrer Delta-Lese-Performance holen: Metadaten-Caching, File Skipping und Partition-Information-Pushdown.
Das offene Tabellenformat Delta
Beginnen wir mit einer kurzen Wiederholung zu Delta, um wieder auf dem Stand zu sein. Delta Lake ist ein Open Table Format, ähnlich wie Apache Iceberg und Apache Hudi. Open Table Formats versteht man am besten als „eine Sammlung von Daten- und Metadaten-Dateien“, die die Flexibilität von Data Lakes bieten sollen, dabei aber einige der Konsistenzgarantien traditioneller Data Warehouses mitbringen. Im Fall von Delta besteht das Format aus Parquet-Dateien für Daten und einer Mischung aus Parquet-, JSON- und Binärdateien für Metadaten. Neben einem höheren Konsistenzniveau erlauben die zusätzlichen Metadaten von Open Table Formats verschiedene Performance-Optimierungen durch Dinge wie Spaltenstatistiken und File Skipping. Für eine ausführlichere Erklärung verweisen wir auf den vorherigen Delta-Blogbeitrag.
Die delta-Erweiterung
DuckDB unterstützt das Lesen von Delta-Tabellen nativ über die delta-Erweiterung. Diese Erweiterung ist eine der DuckDB-Core-Erweiterungen mit >70k wöchentlichen Downloads. Diese Erweiterung zum Lesen einer Delta-Tabelle zu nutzen, ist wirklich einfach. Seit DuckDB v1.2.0 wird die delta-Erweiterung beim ersten Einsatz automatisch installiert und geladen, wenn die Funktion delta_scan aufgerufen wird.
Um zum Beispiel eine lokale Delta-Tabelle zu lesen, öffnen Sie einfach einen beliebigen DuckDB-Client und führen aus:
SELECT * FROM delta_scan('./⟨path_to_your_delta_table⟩');Liegt Ihre Delta-Tabelle auf der Maschine von jemand anderem, vielleicht in AWS? DuckDB kann auch direkt von S3 abfragen! Damit DuckDB Ihre AWS-Credentials automatisch lädt und eine Remote-Delta-Tabelle abfragt, führen Sie aus:
CREATE SECRET (TYPE s3, PROVIDER credential_chain);SELECT * FROM delta_scan('s3://⟨your-bucket⟩/⟨your_delta_table⟩');Für andere Cloud-Anbieter wie Azure oder Google Cloud siehe die Dokumentationsseite der Erweiterung.
Performance-Verbesserungen zwischen delta v0.1.0 und 0.3.0
Während das erste Release (v0.1.0) der delta-Erweiterung bereits verschiedene performance-bezogene Features wie Projection Pushdown und Constant Filter Pushdown mitbrachte, haben die seitdem ergänzten Features die Performance von delta_scan massiv verbessert. Um das zu zeigen, nutzt unser erster Benchmark den Branchenstandard TPC-DS mit dem Scale-Factor-1-Datensatz (SF1).
Benchmark-Setup
Für diesen Benchmark haben wir eine AWS-Instanz c6id.4xlarge (16 vCPUs, 32 GB RAM) gestartet und den TPC-DS-SF1-Datensatz mit PySpark in einen S3-Bucket in derselben Region (eu-west-1) geschrieben. Jeder Benchmark wird insgesamt 6-mal ausgeführt; das Ergebnis ist die Median-Laufzeit der letzten 5 Läufe, der erste gilt als Cold Run. Die aggregierten Ergebnisse zeigt die folgende Tabelle.
| Ergebnis | Gesamtlaufzeit | Min. Laufzeit | Max. Laufzeit | Median-Laufzeit | Queries mit Timeout |
|---|---|---|---|---|---|
delta extension v0.1.0 |
444.76 | 0.48 | 21.31 | 3.63 | 4 |
delta extension v0.3.0 |
151.06 | 0.46 | 6.15 | 1.22 | 0 |
Die detaillierten Ergebnisse des Benchmarks zeigt der Klappbereich:
Analyse
An den Ergebnissen sehen wir, dass es rundum eine signifikante Performance-Verbesserung gab. In v0.1.0 haben 4 von 99 Queries das Benchmark-Timeout von 30 s getroffen und wurden aus den Ergebnissen ausgeschlossen. In v0.3.0 haben alle 99 Queries das Timeout deutlich unterschritten. Vergleichen wir die Gesamtlaufzeiten (ohne die Queries, die für v0.1.0 das Timeout erreicht haben), finden wir eine Beschleunigung von mehr als 3×!
Ohne zu sehr ins Detail zu gehen: Ein wichtiger Teil der Beschleunigung hier lässt sich auf die Kardinalitätsinformations-Propagation zurückführen, die in PR #77 ergänzt wurde. Genaue Kardinalitätsschätzungen sind essenziell, damit DuckDBs Query-Optimizer gut arbeitet und effiziente Query-Pläne erzeugt. Konkret nutzt DuckDBs Join-Optimizer die Kardinalitätsschätzungen, um die Reihenfolge zu ändern, in der Joins ausgeführt werden. Die Join-Reihenfolge kann die Kardinalität der Zwischentupel massiv beeinflussen, was großen Einfluss auf die Query-Performance hat. Besonders in Workloads wie dem TPC-DS-Benchmark, der viele Queries mit vielen Joins enthält, spielt der Join-Order-Optimizer eine entscheidende Rolle. Für (viel) mehr Details siehe diese Thesis.
Weitere Optimierungen
Delta-Tabellen anhängen
Neben den allgemeinen Performance-Verbesserungen wie der Kardinalitätsinformations-Propagation wurden auch mehrere performance-bezogene Features zur delta-Erweiterung ergänzt. Eines davon ist die Fähigkeit, Delta-Tabellen anzuhängen. ATTACH zum Abfragen einer Delta-Tabelle hat mehrere Vorteile. Erstens können Ihre Queries mit ATTACH etwas sauberer aussehen, wenn Sie dieselbe Tabelle mehrfach abfragen, weil Sie den vollen Delta-Tabellenpfad nicht jedes Mal wiederholen müssen. Wichtiger ist aber: Mit ATTACH kann DuckDB bestimmte Teile der Delta-Metadaten cachen/wiederverwenden, was die Query-Performance verbessern kann. Um die lokale Delta-Tabelle anzuhängen, führen Sie aus:
ATTACH './⟨path_to_your_delta_table⟩' AS ⟨your_table⟩ (TYPE delta);Nach dem Anhängen der Delta-Tabelle können Sie die Tabelle einfach über den Alias abfragen:
SELECT * FROM ⟨your_table⟩;Standardmäßig cached DuckDB Delta-Metadaten automatisch innerhalb derselben Transaktion. Das heißt: Wird eine Delta-Tabelle in dieser Transaktion mehrfach gescannt, kann DuckDB Teile der Delta-Metadaten zwischen den verschiedenen Scans wiederverwenden. Zum Beispiel liest die folgende Query die Delta-Metadaten nur einmal:
SELECT * FROM t1UNION ALLSELECT * FROM t1;Für noch mehr Performance-Verbesserungen unterstützt DuckDB auch das Persistieren dieser gecachten Delta-Metadaten zwischen verschiedenen Queries. Dazu kann die Delta-Tabelle mit der Option PIN_SNAPSHOT angehängt werden. Mit dieser Option können nachfolgende Queries die Metadaten wiederverwenden, wie im folgenden Codeblock:
ATTACH 's3://⟨your-bucket⟩/⟨your_delta_table⟩' AS t2 ( TYPE delta, PIN_SNAPSHOT);
-- First scan (metadata not yet cached)SELECT * FROM t1;
-- Second scan (metadata is now cached)SELECT * FROM t2;Metadaten-Caching kann einen signifikanten Performance-Effekt haben, besonders wenn die Daten relativ klein und die Latenz hoch ist. Um das zu zeigen, führen wir unser TPC-DS-Experiment erneut aus und vergleichen drei verschiedene Wege, die Delta-Tabelle zu scannen: mit delta_scan, mit ATTACH und mit ATTACH ... (PIN_SNAPSHOT). Der Rest des Benchmark-Setups ist identisch zum vorherigen Abschnitt.
| Ergebnis | Gesamtlaufzeit | Min. Laufzeit | Max. Laufzeit | Median-Laufzeit |
|---|---|---|---|---|
delta_scan |
151.06 | 0.46 | 6.15 | 1.22 |
ATTACH |
134.26 | 0.43 | 4.28 | 1.19 |
ATTACH (PIN_SNAPSHOT) |
102.80 | 0.36 | 4.04 | 0.87 |
Die detaillierten Ergebnisse des Benchmarks zeigt der Klappbereich:
Die Ergebnisse zeigen, dass für viele TPC-DS-Queries ATTACH statt delta_scan die Performance für mehrere Queries bereits leicht verbessern kann; die Gesamtlaufzeit sieht eine 1,13× Beschleunigung. Sind die Metadaten dank PIN_SNAPSHOT vollständig im Cache, sehen wir eine noch größere Beschleunigung von 1,47×. Das kommt aber zum Preis, Updates an der Tabelle zu verpassen, die nach dem ATTACH-Statement auftreten.
Ein scharfes Auge auf die vollständigen Ergebnisse entdeckt auch ein paar Fälle, in denen die ATTACH-Ergebnisse tatsächlich etwas schlechter sind als die Ergebnisse mit rohem delta_scan. Das erklären wir im Abschnitt zum Zusammenspiel von Pushdown und ATTACH.
File Skipping
Ein weiteres zentrales Performance-Feature beim Scannen von Delta-Tabellen ist File Skipping. Wie in der Einleitung erklärt, enthalten Delta-Tabellen Metadaten mit allerlei Statistiken der Datendateien der Tabelle. Diese Statistiken können Engines wie DuckDB nutzen, um zu entscheiden, welche Parquet-Dateien gescannt werden müssen und welche ganz übersprungen werden können. File Skipping geschieht automatisch durch DuckDB. File Skipping funktioniert sowohl für konstante als auch für dynamische Filter (Filter, die während der Query-Ausführung berechnet werden):
-- constant filterFROM delta_scan('...')WHERE col_a > 'some_value';-- dynamic filterFROM delta_scan('...')WHERE col_a > (SELECT max(col_z) FROM other_tbl);In den vorherigen Benchmarks hat File Skipping nur einen sehr begrenzten Effekt. Die Gesamtdaten sind einfach nicht groß genug, und viele Queries berühren ohnehin große Teile der Daten. Wird in einer Query aber nur ein relativ kleiner Teil der Daten berührt, kann File Skipping einen enormen Einfluss auf die Performance haben. Um das zu zeigen, erzeugen wir zuerst einige Testdaten. Wir nutzen dasselbe PySpark-basierte Testdaten-Generierungsskript wie zuvor.
Die Tabelle hat 100 Millionen Zeilen und ein sehr einfaches Schema mit einer inkrementierenden id-Spalte vom Typ INTEGER und einer value-Spalte vom Typ VARCHAR. Fragen wir die Daten mit DuckDB ab, sehen wir etwas wie:
FROM delta_scan('s3://⟨your-bucket⟩/⟨your_delta_table⟩');┌──────────┬──────────────┐│ id │ value ││ int64 │ varchar │├──────────┼──────────────┤│ 49950000 │ val-49950000 ││ 49950001 │ val-49950001 ││ 49950002 │ val-49950002 ││ 49950003 │ val-49950003 ││ · │ · ││ · │ · ││ · │ · ││ 49996 │ val-49996 ││ 49997 │ val-49997 ││ 49998 │ val-49998 ││ 49999 │ val-49999 │├──────────┴──────────────┤│ 100000000 rows ││ (8 shown) │└─────────────────────────┘Sagen wir, wir sind nur an einem bestimmten Bereich von ids interessiert: vielleicht wollen wir nur ids unter 100. Wir konstruieren jetzt zwei Queries.
Für die erste Query lesen wir direkt alle in der Tabelle gespeicherten Parquet-Dateien mit einem Glob-Muster:
FROM parquet_scan('s3://⟨your-bucket⟩/⟨your_delta_table⟩/*.parquet')WHERE id < 100;Wir tun das zur Veranschaulichung, um die Vorteile von File Skipping zu zeigen. Das Scannen der rohen Parquet-Dateien in einer Delta-Tabelle funktioniert hier nur, weil wir in dieser Tabelle keine Updates, Deletes oder Checkpoints haben.
Für die zweite Query scannen wir die Tabelle direkt mit der Tabellenfunktion delta_scan und selektieren nur die ids, die uns interessieren, mit einer WHERE-Klausel:
FROM delta_scan('s3://⟨your-bucket⟩/⟨your_delta_table⟩')WHERE id < 100;Führen wir diese Queries von einer AWS-Instanz c6id.4xlarge auf dem S3-Bucket in derselben Region aus, sehen wir, dass sie sich stark unterscheiden. delta_scan braucht nur ≈0,5 Sekunden, während parquet_scan ≈17 Sekunden braucht. Was genau passiert hier?
Wir können DuckDBs Statement EXPLAIN ANALYZE nutzen, um mehr Details zu bekommen. Beginnen wir mit der Analyse von parquet_scan:
EXPLAIN ANALYZEFROM parquet_scan('s3://⟨your-bucket⟩/⟨your_delta_table⟩/*.parquet')WHERE id < 100;┌────────────────────────────────────────────────┐│┌──────────────────────────────────────────────┐│││ Total Time: 17.08s │││└──────────────────────────────────────────────┘│└────────────────────────────────────────────────┘ ...┌─────────────┴─────────────┐│ TABLE_SCAN ││ ──────────────────── ││ Function: ││ PARQUET_SCAN ││ ││ Projections: ││ id ││ value ││ ││ Filters: id<100 ││ ││ 100 Rows ││ (262.39s) │└───────────────────────────┘In der EXPLAIN ANALYZE-Ausgabe sehen wir, dass unser Filter korrekt gepusht wurde und der Scan korrekt nur 100 Zeilen erzeugt hat. Das sieht alles in Ordnung aus, oder? Vergleichen wir es mit der EXPLAIN ANALYZE-Ausgabe für delta_scan:
EXPLAIN ANALYZEFROM delta_scan('s3://⟨your-bucket⟩/⟨your_delta_table⟩');┌────────────────────────────────────────────────┐│┌──────────────────────────────────────────────┐│││ Total Time: 0.615s │││└──────────────────────────────────────────────┘│└────────────────────────────────────────────────┘ ...┌─────────────┴─────────────┐│ TABLE_SCAN ││ ──────────────────── ││ Projections: ││ id ││ value ││ ││ Filters: id<100 ││ File Filters: id<100 ││ ││ Scanning Files: ││ 1/2000 ││ ││ 100 Rows ││ (0.06s) │└───────────────────────────┘In der EXPLAIN ANALYZE-Ausgabe der Funktion delta_scan sehen wir zwei neue Felder: File Filters und Scanning Files. Das zeigt klar, was vor sich geht. Das Prädikat id<100 wird jetzt für zwei Dinge genutzt: Es wird in die Scans der einzelnen Parquet-Dateien gepusht, genau wie bei parquet_scan, erscheint aber auch als File Filter, der die Liste der zu scannenden Dateien insgesamt reduziert! Das führt zu einer 2.000× Reduktion der zu lesenden Parquet-Metadatenmenge, was einen enormen Performance-Schub ergibt.
Partition-Information-Pushdown
Das letzte DuckDB-Delta-Performance-Feature ist Partition-Information-Pushdown. Partition-Information-Pushdown und der partition-aware Aggregation-Operator sind relativ neue Features, die in DuckDB v1.2.0 eingeführt wurden. Im Release v0.3.0 der delta-Erweiterung wurde dieses Feature ebenfalls ergänzt: DuckDB kann die Partitionierungsinformationen jetzt nutzen, um Query-Pläne zu erzeugen, die ausnutzen, dass die gescannten Daten bereits partitioniert sind.
Um den Performance-Vorteil von Partitionsinformationen zu zeigen, führen wir – Überraschung – einen weiteren Benchmark aus! Diesmal wählen wir den TPC-H-Datensatz bei Scale Factor 10 und führen das Experiment auf einem 32-GB-MacBook Pro M1 Max aus. Wir haben die Tabelle lineitem nach den Spalten l_returnflag und l_linestatus partitioniert. Dann führen wir Q1 aus, die ungefähr so aussieht:
SELECT l_returnflag, l_linestatus, sum(l_quantity) AS sum_qty, ...FROM lineitem ...GROUP BY l_returnflag, l_linestatus ...;Beachten Sie, dass die Query ein GROUP BY-Statement enthält, das genau die Spalten auflistet, nach denen unser Datensatz bereits partitioniert ist. DuckDB partitionierungsbewusste Operatoren nutzen zu lassen, geschieht automatisch; in diesem Fall genügt:
ATTACH './⟨path_to_partitioned_directory⟩/lineitem_sf10' AS lineitem ( TYPE delta);PRAGMA tpch(1);um TPC-H Q1 auf dem partitionierten Delta-Datensatz zu starten. Um zu prüfen, was passiert, nutzen wir wieder EXPLAIN ANALYZE:
┌────────────────────────────────────────────────┐│┌──────────────────────────────────────────────┐│││ Total Time: 0.477s │││└──────────────────────────────────────────────┘│└────────────────────────────────────────────────┘ ...┌─────────────┴─────────────┐│ PARTITIONED_AGGREGATE ││ ──────────────────── ││ Groups: ││ #0 ││ #1 ││ ││ Aggregates: ││ sum(#2) ││ sum(#3) ││ sum(#4) ││ sum(#5) ││ avg(#6) ││ avg(#7) ││ avg(#8) ││ count_star() ││ ││ 4 Rows ││ (0.65s) │└─────────────┬─────────────┘ ...Wir sehen, dass DuckDB die Partitionierungsinformationen korrekt erkannt hat und den Operator PARTITIONED_AGGREGATE nutzt, um das GROUP BY effizient auszuführen.
Als Baseline führen wir dieselbe Query erneut aus, jetzt aber mit deaktiviertem Partition-Information-Pushdown:
ATTACH './⟨path_to_partitioned_directory⟩/lineitem_sf10' AS lineitem ( TYPE delta, PUSHDOWN_PARTITION_INFO 0);PRAGMA tpch(1);Wieder sehen wir mit EXPLAIN ANALYZE, dass DuckDB jetzt einen regulären Operator HASH_GROUP_BY nutzt, weil die Partitionsinformationen aus Delta bei der Query-Planung nicht verfügbar waren.
┌────────────────────────────────────────────────┐│┌──────────────────────────────────────────────┐│││ Total Time: 0.552s │││└──────────────────────────────────────────────┘│└────────────────────────────────────────────────┘ ...┌─────────────┴─────────────┐│ HASH_GROUP_BY ││ ──────────────────── ││ Groups: ││ #0 ││ #1 ││ ││ Aggregates: ││ sum(#2) ││ sum(#3) ││ sum(#4) ││ sum(#5) ││ avg(#6) ││ avg(#7) ││ avg(#8) ││ count_star() ││ ││ 4 Rows ││ (1.37s) │└─────────────┬─────────────┘ ...Schauen wir auf die Performance-Unterschiede zwischen diesen beiden Queries: Die Gesamtbeschleunigung ist nur ein bescheidenes 1,16×, die Aggregationsoperation selbst wurde aber um 2,11× beschleunigt! Das heißt: Bei Queries, die regelmäßig schwere Group-by-Operationen machen, kann das Partitionieren der Daten nach diesen Spalten durchaus ein sehr nützliches Werkzeug in Ihrer Performance-Tuning-Toolbox sein.
Anmerkung zum Zusammenspiel von Pushdown- und ATTACH-Performance
Während Features wie Filter-Pushdown und Partition-Information-Pushdown die Performance für viele Workloads verbessern, ist es nützlich zu wissen, dass es ein etwas vertracktes Zusammenspiel zwischen dem Metadaten-Caching-Mechanismus von ATTACH und dem Pushdown von Filtern und Partitionsinformationen gibt. Am Ende des Abschnitts zum ATTACH-Feature haben wir bereits gesehen, dass für manche Queries ATTACH tatsächlich etwas langsamer ist als das rohe delta_scan. Ohne zu sehr ins Detail zu gehen: Das Pushen von Filtern und Partitionierungsinformationen kann die Wirksamkeit des Metadaten-Cachings für manche Queries negativ beeinflussen. Das heißt, bei manchen Queries können Sie – etwas kontraintuitiv – davon profitieren, Filter-Pushdown teilweise zu deaktivieren, wenn Sie ATTACH nutzen:
ATTACH './⟨your_delta_table_directory⟩' AS dt ( TYPE delta, PIN_SNAPSHOT, PUSHDOWN_PARTITION_INFO 0, PUSHDOWN_FILTERS 'none');Das sollte aber als fortgeschrittener Anwendungsfall gelten und nur relevant sein, wenn Sie für spezifische Queries optimieren. Die Standardeinstellungen von ATTACH sollten die beste Gesamt-Performance liefern und sind für die meisten Fälle empfohlen. Außerdem gibt es laufende Arbeit in der zugrunde liegenden Bibliothek delta-kernel-rs, die DuckDB Delta nutzt, die darauf abzielt, diesen Effekt zu reduzieren, indem Mechanismen bereitgestellt werden, um von DuckDB gehaltene Metadatenobjekte clever zu refreshen. Sobald diese Mechanismen verfügbar sind, werden wir sie zur DuckDB-delta-Erweiterung ergänzen, wodurch diese Flags wahrscheinlich für alles außer Tests überflüssig werden.
Fazit
In diesem Blogbeitrag haben wir uns die neueste Version der DuckDB-delta-Erweiterung angeschaut und sie mit einigen Benchmarks auf die Probe gestellt. Wir haben Queries aus den Branchenstandard-TPC-Benchmarks ausgeführt, um die großen Performance-Verbesserungen der letzten Releases der delta-Erweiterung zu zeigen.
Außerdem haben wir uns drei konkrete Techniken angeschaut, die beim Arbeiten mit Delta-Tabellen genutzt werden können, um die Performance noch weiter zu verbessern:
- Metadaten-Caching mit
ATTACH - Filter und Datenlayout nutzen, um die Zahl der zu scannenden Dateien zu reduzieren
- Partitionierungsinformationen nutzen, um Aggregationen zu beschleunigen
Alles in allem denken wir, dass DuckDB mit dem Release v0.3.0 der delta-Erweiterung Delta-Tabellen mit exzellenter Performance für viele verschiedene Workloads lesen kann, und ermutigen alle sehr, die neueste Version auszuprobieren!