2026-07-31
Asynchrones I/O in DuckDB: Work, Thread, Work
Pedro Holanda
Es spielt keine Rolle, wie schnell die Query-Operatoren in einem Datenbanksystem sind, wenn wir die Daten nicht schnell genug holen. Über den Großteil von DuckDBs Geschichte haben wir dieses Problem weitgehend umgangen, indem wir Daten früh ausgeschnitten haben. Mit Filter- und Projektions-Pushdown haben wir sichergestellt, dass wir nur lesen, was wir wirklich brauchen.
Das funktionierte besonders gut, weil DuckDB primär lokal lief, als flinke Datenbank-Engine für Abfragen direkt von der SSD der Maschine. Wir konnten die Daten in Partitionen zerlegen, etwa Row Groups für Parquet-Dateien oder fest große Puffer für CSV-Dateien, und sie mit niedriger Latenz und hoher Bandbreite laden. Die eigentlichen Engpässe lagen woanders: Subqueries, Joins, Aggregationen und so weiter. Der Datenzugriffspfad bekam weniger Aufmerksamkeit, weil synchroner Zugriff für diesen Anwendungsfall völlig ausreichte.
Wie so oft hat sich das geändert. Wir haben gesehen, dass DuckDBs Architektur gut zu großen, remote gespeicherten Datensätzen passt, etwa Data Lakes (z. B. DuckLake). Seit Mai dieses Jahres können wir DuckDB sogar als Server über das Quack-Protokoll betreiben. Die ursprüngliche Annahme, dass Datendateien auf einer lokalen SSD liegen, gilt also nicht mehr immer.
Die praktische Folge: Viele aktuelle DuckDB-Setups müssen Dateien von Remote-Storage auf die Maschine holen, die sie verarbeitet. Bei Data Lakes etwa liegen die Daten typischerweise in Blob Storage wie S3 und werden auf einer EC2-Maschine in derselben Region verarbeitet. In diesem Setup spielen Latenz und Bandbreite eine deutlich größere Rolle. Wenn wir nicht genug parallele Requests absetzen, um die verfügbare Netzbandbreite zu nutzen, leidet die Performance stark: Threads warten einen Großteil der Zeit auf Remote-Reads, statt Daten zu verarbeiten.
Als Beispiel eine einfache Abfrage über eine remote liegende Parquet-Datei. Der Einfachheit halber nehmen wir an, dass nur ein Thread läuft.
FROM read_parquet('s3://bucket/file.parquet');Ein Parquet-Scan ist in Row-Group-basierte Jobs zerlegt, jeder Job enthält eine oder mehrere Fetch-Tasks, die Byte-Range-Requests absetzen. Bei synchronem I/O blockiert der Worker-Thread, bis die Daten auf der Maschine ankommen, bevor er echte Arbeit macht: Dekodieren, Aggregieren und so weiter. Die Abbildung unten zeigt das: Der Thread ist blockiert und kann nichts tun, solange der Read nicht fertig ist.
Dagegen implementieren wir asynchrone I/O-Pipelines in DuckDB. Sie sind derzeit für Parquet und für unkomprimierte, seekbare UTF-8-CSV-Dateien umgesetzt, Unterstützung für andere Formate wie DuckDBs natives Format und JSON folgt noch. Im Rest dieses Beitrags erklären wir, wie asynchrones I/O in DuckDB umgesetzt ist, und zeigen Benchmarks für Parquet- und CSV-Dateien.
Wer asynchrones I/O jetzt ausprobieren möchte, kann das mit DuckDBs v2.0.0-dev-Preview-Builds tun. Asynchrones I/O wird ab der nächsten Major-Version von DuckDB, v2.0, im Herbst standardmäßig genutzt.
Asynchrones I/O
Die Idee von asynchronem I/O ist einfach: Wir sollen eine I/O-Operation starten können, ohne den Worker-Thread zu blockieren, der sie angefordert hat. Auf unser Parquet-Beispiel angewandt sähe dasselbe Bild so aus:
In diesem Beispiel haben wir zwei ASYNC-Threads und einen normalen Worker-Thread. Die ASYNC-Threads halten Fetch-Tasks in der Luft, während der Worker-Thread Daten dekodiert. Während des initialen Warm-ups parkt der Scan-Task, der Worker-Thread kann andere Pipeline-Tasks ausführen. Sobald der erste Job bereit ist, können Fetch und Decode überlappen.
In DuckDB haben wir etwas Ähnliches umgesetzt. Wir haben zwei getrennte Thread-Pools:
REGULAR– Dieser Pool enthält unsere Worker-Threads (standardmäßig: einer pro verfügbarem CPU-Thread). Sie machen die echte Arbeit: Dekodieren, Joins, Aggregationen. Sie priorisieren reguläre Arbeit, können aber bei Leerlauf auch I/O-Tasks übernehmen.ASYNC– Ein Pool für asynchrone Tasks, vor allem blockierendes I/O.
Der Hauptgrund für zwei Pools: Bei Remote-I/O verbringen diese Threads fast die gesamte Zeit blockiert, etwa wartend auf eine HTTP-Antwort, und haben deshalb kaum CPU-Auslastung. Deshalb haben wir deutlich mehr ASYNC-Worker als System-Threads, der Default ist 4 * system threads, gedeckelt bei 256.
Es ist entscheidend, möglichst viele ASYNC-Threads beschäftigt zu halten. Dafür implementieren wir eine Read-Ahead-Strategie statt Reads on demand. Das heißt: Fetch-Tasks werden voraus geplant, weiter als das, was die regulären Worker-Threads gerade brauchen.
Achten müssen wir darauf, dass Read-Ahead Durchsatz gegen Speicher tauscht. Wenn das Dekodieren langsam und das Netz schnell ist, können vorgeholte Daten sich ansammeln und zu Out-of-Memory-Problemen führen. Dagegen haben wir asynchrone Memory Governance eingeführt. Read-Ahead und Memory Governance erklären wir in den folgenden Abschnitten genauer.
Read-Ahead-Queue
Die Idee von Read-Ahead ist ebenfalls geradlinig. Statt einen Read genau dann zu starten, wenn ein regulärer Worker die Daten braucht, planen wir Fetch-Tasks für Arbeit, die weiter vorn liegt. Während ein regulärer Worker den aktuellen Job dekodiert, ziehen die ASYNC-Threads schon Daten für die nächsten Jobs. Ziel ist, genug Fetch-Tasks in der Luft zu halten, um die Latenz von Remote-Storage zu verstecken.
Jobs sind Arbeitseinheiten, die unabhängig geplant und verarbeitet werden können, und sie unterscheiden sich je nach Dateiformat. Bei einer Parquet-Datei ist ein Job eine Row Group einer Datei. Bei einer CSV-Datei ist ein Job eine Scan-Grenze, die in der Regel einen festen Byte-Bereich in der Datei abdeckt.
Ein Parquet-Job kann in mehrere Fetch-Tasks zerlegt werden, abhängig von Query-Projektionen, Filter-Pushdowns, physischen Spaltenlagen und davon, welche benachbarten Byte-Ranges sich zusammenfassen lassen. Die zwei Fetch-Tasks in der Abbildung unten sind illustrativ, ihre genaue Gruppierung und Größe hängen von Datei und Abfrage ab.
Bei CSV-Dateien haben wir nicht dieselbe Granularität wie bei Parquet. Die Fetch-Tasks eines Jobs laden seinen Startpuffer, falls er nicht schon im Speicher liegt, und, wenn die Scan-Grenze das Ende dieses Puffers erreicht, auch den folgenden Puffer (z. B. für Zeilen, die über zwei Puffer gesplittet sind).
Das Füllen der Queue braucht keinen eigenen Producer-Thread. Jeder reguläre Worker, der nach Scan-Arbeit sucht, füllt die Queue zuerst so weit auf, wie es erlaubt ist. Die Grenze ist entweder eine nutzerdefinierte Zahl von Slots oder ein Speicherbudget. Wenn Platz ist, werden ein Job und seine Fetch-Tasks erzeugt. Die Fetch-Tasks werden sofort auf dem ASYNC-Pool geplant, der Job kommt in Batch-Reihenfolge in die Read-Ahead-Queue.
ASYNC-Threads führen einzelne Fetch-Tasks unabhängig von der Claim-Reihenfolge der Job-Queue aus. Fetch-Tasks desselben Jobs können parallel laufen, eine bestimmte Zuordnung zu ASYNC-Threads ist nicht garantiert. Alle Fetch-Tasks eines Jobs teilen sich einen Countdown, der Fetch-Task, der ihn auf null bringt, schließt das I/O des Jobs ab.
Ein Worker-Thread beansprucht den ältesten Job in der Queue und prüft diesen Countdown. Ist das I/O fertig, beginnt der Worker mit dem Dekodieren. Wenn nicht, parkt er den Scan-Task und kann andere Pipeline-Tasks ausführen. Der letzte Fetch-Task entsperrt den Scan-Task, der auf einem beliebigen regulären Worker weiterlaufen kann.
Das Beanspruchen des Jobs gibt sofort einen Queue-Slot frei, sodass jeder reguläre Worker, der nach Scan-Arbeit sucht, einen Ersatzjob ans Ende der Queue stellen kann. Die Abbildung unten zeigt diesen Zyklus:
Speichermanagement
Mehr Fetch-Tasks in der Luft verbrauchen mehr Speicher. Um ein Budget festzulegen und Out-of-Memory-Probleme zu vermeiden, haben wir die Konfigurationsoption read_ahead_depth eingeführt. Sie kann drei Arten von Werten haben:
-1(Default): unbegrenzte Tiefe, begrenzt durch den Speicher.N > 0: höchstensNJobs voraus, ohne Speicherbudget.0: Read-Ahead ist aus, jeder Scan-Task plant I/O nur für seinen eigenen Job.
Konfiguriert wird sie mit SET, z. B.:
SET read_ahead_depth = 5;Im Default-Modus wird das Budget mit dem Temporary Memory Manager verhandelt, demselben Manager, der Speicher zwischen parallelen Joins, Sorts und Window-Operatoren aufteilt. Bei starkem Speicherdruck, etwa weil ein Operator viel Speicher nutzt, können Queue-Reservierungen sofort über Budget liegen. Praktisch heißt das: Die Queue lässt nur einen Job gleichzeitig zu, und der Scan verhält sich fast wie ein synchroner Scan.
Wenn der speicherlastige Operator fertig ist, hat der Memory Manager wieder Budget, und die Queue füllt sich erneut.
Benchmarks
Asynchrones I/O sollte den größten Effekt haben, wenn die Latenz synchroner Requests uns daran hindert, die verfügbare Remote-Bandbreite zu nutzen. Um das zu messen, haben wir TPC-H Query 6 bei SF100 ausgeführt, Daten auf S3, und die Ergebnisse mit DuckDB v1.5.5 verglichen, unserem aktuellen stabilen Release. Der SF100-Datensatz lag als eine Datei pro Tabelle für die Parquet- und CSV-Benchmarks vor, die Tabelle lineitem enthält 600.037.902 Zeilen.
Als Compute nutzten wir eine EC2-r7i.16xlarge (64 vCPUs und 512 GB RAM), Maschine und S3-Bucket lagen in derselben Region. Wir haben die Abfrage fünfmal ausgeführt und berichten die mittlere Laufzeit. Die Dateien wurden nie gecacht (also SET enable_external_file_cache = false;), jede Ausführung las die Daten direkt von S3.
Parquet
Die Parquet-Datei ist etwa 22 GB groß und hat rund 4.880 Row Groups, jede mit etwa 122.880 Zeilen. Mit asynchronem I/O sinkt die mittlere Laufzeit von 8,230 Sekunden auf 2,844 Sekunden, die Abfrage wird fast 3× schneller.
| Version | Q6-Laufzeit |
|---|---|
| v1.5.5 (synchron) | 8,230 s |
| v2.0.0-dev (asynchrones I/O) | 2,844 s |
Unten zeigen wir außerdem den Netzwerkdurchsatz über den Verlauf der Abfrage:
Darin laufen DuckDB v1.5.5 und zwei Varianten von DuckDB v2.0.0-dev. Eine mit Read-Ahead-Tiefe durch den Memory Governor, und eine auf diese Maschine abgestimmte, bei der wir Read-Ahead auf 64 gleichzeitige Jobs deckeln und die I/O-Einstellungen anpassen (SET async_threads = 48; SET http_retries = 8; SET http_retry_wait_ms = 50; SET http_retry_backoff = 2). Man sieht, dass v2.0.0-dev die verfügbare Bandbreite deutlich besser nutzt, sich dem Netzlimit nähert und es an mehreren Stellen erreicht. Die getunte Version geht weiter. Mit weniger, heißeren Verbindungen und günstigen Retries sinkt die Durchsatzvarianz auf ein Minimum, und das 25-Gbit/s-Netz bleibt fast durchgängig gesättigt. Ihre Query-Zeit lag bei 2,227 Sekunden, 21,7 % unter dem ungetunten v2.0.0-dev-Lauf und etwa 3,7× schneller als DuckDB v1.5.5. Zum Vergleich bleibt v1.5.5 bei etwa 5 Gbit/s, weil die synchronen Reads nicht genug Requests in der Luft halten, um das Netz zu sättigen.
Noch eine Beobachtung: In allen Experimenten vergehen ein paar hundert Millisekunden, bevor der erste Ausschlag im Netztraffic kommt, und noch einmal ein paar hundert Millisekunden, bevor der eigentliche Datentransfer beginnt. Die erste Lücke ist die Zeit, um eine DuckDB-Verbindung zu öffnen, den ersten TLS-Handshake zu machen und die Datei zu öffnen. Der Ausschlag entspricht dem Download des Datei-Footers, die zweite Lücke kommt vom Verarbeiten der Footer-Informationen vor der Query-Ausführung. Das ist ein Bereich, den wir vor dem v2.0-Release noch untersuchen und optimieren wollen.
Wir haben den Received-Byte-Zähler der NIC alle 50 ms gesampelt und den Durchsatz aus der Änderung der Bytes zwischen den Samples berechnet. Unabhängig haben wir bestätigt, dass die Maschine das Netz mit 25 Gbit/s erreichen kann, sowohl mit einem DuckDB-Full-File-Read als auch mit dem Tool
s5cmd.
Lokale Platte
Remote-Storage ist das Hauptziel für asynchrones I/O, kalte lokale Reads geben aber einen nützlichen Kontrast. Dafür haben wir TPC-H Query 6 über die SF100-Parquet-Datei ausgeführt, diesmal auf der lokalen Platte eines MacBook Pro (Apple M4 Max, 14 Kerne und 36 GB RAM). Weil der Nutzen von asynchronem I/O auf lokalen Platten von kalten Reads kommt, haben wir zwischen den Läufen die OS-Caches geleert (mit dem macOS-Befehl purge), sodass jede Ausführung die Datei tatsächlich von der Platte gelesen hat.
| Version | Q6-Laufzeit |
|---|---|
| v1.5.5 (synchron) | 1,321 s |
| v2.0.0-dev (asynchrones I/O) | 0,883 s |
Bei Cold Runs ist asynchrones I/O etwa 1,5× schneller, die Laufzeit sinkt um rund 33 %. Der Unterschied ist deutlich kleiner als in den Fällen oben, weil die SSD deutlich niedrigere Latenz und deutlich höhere Bandbreite hat als das EC2/S3-Netz. Bei Hot Runs ist der Unterschied vernachlässigbar, weil bei korrekt gecachten Daten kein Plattenzugriff stattfindet.
Kleine Dateien
Partitionierte Datensätze sind hier ein besonders relevanter Fall, weil Partitionierung die Daten leicht über viele kleine Dateien verteilt. Um zu sehen, wie asynchrones I/O sich in diesem Setup verhält, haben wir einen weiteren Parquet-Lauf mit demselben TPC-H-SF100-Datensatz gemacht. Statt einer Datei haben wir 976 Dateien mit je fünf Row Groups erzeugt. Jede Datei enthält etwa 615.000 Zeilen und ist rund 22 MB groß.
| Version | Q6-Laufzeit |
|---|---|
| v1.5.5 (synchron) | 9,344 s |
| v2.0.0-dev (asynchrones I/O) | 2,945 s |
v2.0.0-dev liefert hier eine ähnliche Verbesserung wie im Single-File-Benchmark, rund 3× schneller. Read-Ahead kann also auch über mehrere Dateien parallelisieren, ohne am Öffnen der Dateien oder am Holen ihrer Footer hängen zu bleiben.
Große Row Groups
Wir wollten auch das andere Extrem sehen: eine Parquet-Datei mit nur wenigen sehr großen Row Groups. Für dieses Experiment haben wir sechs Versionen derselben TPC-H-SF100-Tabelle lineitem als einzelne Datei erzeugt, geändert haben wir nur die angeforderte Row-Group-(RG-)Größe, und Q6 mit DuckDB v2.0.0-dev ausgeführt. Die Tabelle unten berichtet die Laufzeit jeder Version.
| Zeilen / RG | RGs | Ca. RG-Größe (MB) | Gesamtdateigröße (MB) | Zeit |
|---|---|---|---|---|
| 122.880 | 4.886 | ~4 MB | ~21.600 MB | 2,74 s |
| 1.966.080 | 306 | ~70 MB | ~21.400 MB | 2,11 s |
| 9.375.593 | 64 | ~320 MB | ~20.500 MB | 2,27 s |
| 62.914.560 | 10 | ~1.500 MB | ~14.700 MB | 3,69 s |
| 150.009.476 | 4 | ~3.200 MB | ~12.800 MB | 8,01 s |
| 600.037.902 | 1 | ~12.300 MB | ~12.300 MB | 25,26 s |
Zunächst senken größere Row Groups die Query-Zeiten. Mit wachsender Row-Group-Größe wird Request-Latenz über deutlich größere Transfers amortisiert. Ab einem gewissen Punkt fällt die verfügbare Parallelität aber. Eine Row Group ist DuckDBs Einheit der Parquet-Scan-Parallelität, idealerweise sollte ein Scan mindestens eine Row Group pro System-Thread bereitstellen. Auf dieser 64-vCPU-Maschine liefert die Version mit 64 Row Groups genau das und endet in 2,27 Sekunden, der schnellste Lauf kommt von der Version mit 306 Row Groups, 2,11 Sekunden.
Mit weniger Row Groups als Threads verlieren wir Parallelität und können das Netz nicht mehr sättigen. Für Q6 ergeben die Projektionen und die physische Lage der Spalten zwei Fetch-Requests pro Row Group. Vier Row Groups bedeuten also nur etwa acht parallele S3-Streams, die Laufzeit steigt auf 8,01 Sekunden. In der größten Konfiguration enthält die Datei eine einzige Row Group, das I/O reduziert sich faktisch auf zwei riesige Streams, die Laufzeit steigt auf 25,26 Sekunden. Das, obwohl bessere Kompression die Datei etwas mehr als halb so groß macht wie die Version mit 4.886 Row Groups. Hier ist die Extra-Bandbreite kleinerer Row Groups günstiger als die verlorene Parallelität extrem großer.
Parallele Abfragen
Der Effekt wird noch klarer, wenn mehrere Abfragen gleichzeitig laufen. Für dieses Experiment haben wir die TPC-H-Queries 1, 6, 9 und 18 parallel gegen denselben SF100-Parquet-Datensatz auf S3 ausgeführt, in einer einzigen DuckDB-Instanz. Wir haben diese Queries gewählt, weil sie eine Mischung aus Scans, Aggregationen und Joins mit unterschiedlichen CPU- und Speicheranforderungen abdecken. Wir haben das Experiment mit der Default-Speicherkonfiguration und mit Speicherlimits von 16 GB und 8 GB wiederholt. Die Gesamtlaufzeit ist die Wall-Clock-Zeit, bis alle vier Queries fertig sind. Wir berichten mittlere und Peak-CPU-Auslastung (genutzte Kerne), Peak-Bandbreite (bw.) und Peak Resident Set Size (RSS).
| Version | Speicherlimit | Laufzeit | Mittlere CPU | Peak-CPU | Peak-bw. | Peak-RSS |
|---|---|---|---|---|---|---|
| v1.5.5 | Default | 35,8 s | 5,9 | 35,7 | 10,7 Gbit/s | 14,5 GB |
| v2.0.0-dev | Default | 15,6 s | 48,1 | 64,0 | 24,9 Gbit/s | 20,1 GB |
| v1.5.5 | 16 GB | 35,6 s | 6,1 | 25,7 | 17,4 Gbit/s | 14,1 GB |
| v2.0.0-dev | 16 GB | 22,7 s | 35,2 | 63,4 | 24,8 Gbit/s | 15,7 GB |
| v1.5.5 | 8 GB | 35,9 s | 6,9 | 38,8 | 16,8 Gbit/s | 10,4 GB |
| v2.0.0-dev | 8 GB | 24,2 s | 30,3 | 63,7 | 25,0 Gbit/s | 11,5 GB |
Mit der Default-Speicherkonfiguration hält DuckDB v1.5.5 im Mittel nur etwa 6 der 64 Kerne beschäftigt. Anders gesagt: Rund 90 % der Maschine sitzen idle und warten auf synchrone S3-Reads. DuckDB v2.0.0-dev dagegen kommt im Mittel auf 48 beschäftigte Kerne, erreicht alle 64 im Peak und sättigt das 25-Gbit/s-Netz. Dadurch sind alle vier Queries in weniger als der Hälfte der Zeit fertig.
Die Speicherergebnisse sind ebenfalls interessant. Senken wir das Limit, reduziert der Memory Governor den Read-Ahead-Rückstau, während speicherlastige Operatoren wie in Q18 auf die Platte auslagern können. Das senkt den Peak-Physikspeicher des DuckDB-Prozesses (also RSS) von DuckDB v2.0.0-dev von 20,1 GB mit Default-Konfiguration auf 15,7 GB bei 16-GB-Limit und 11,5 GB bei 8-GB-Limit. Zusätzliches Spilling und reduziertes Read-Ahead senken auch die mittlere CPU-Auslastung und erhöhen die Laufzeit, v2.0.0-dev sättigt das Netz aber weiter und bleibt in beiden Fällen deutlich schneller als v1.5.5.
Man bemerkt vielleicht, dass das 8-GB-Ergebnis trotzdem bei 11,5 GB RSS peak’t. Das liegt daran, dass jemalloc kürzlich freigegebene Pages etwa eine Sekunde resident hält, damit sie wiederverwendet werden können. Dieser Speicher zählt nicht mehr im DuckDB-Memory-Manager, und v1.5.5 zeigt dasselbe Allocator-Verhalten.
CSV
Der Effekt ist bei CSV-Dateien größer. Die CSV-Datei ist 80,89 GB groß, asynchrones I/O senkt die mittlere Laufzeit von 878 Sekunden auf nur 45 Sekunden, die Abfrage wird fast 20× schneller. CSV ist zeilenorientiert, der Scan überträgt also deutlich mehr Daten und macht Reads mit fester Puffergröße – parallele Remote-Reads sind hier besonders wertvoll.
| Version | Q6-Laufzeit |
|---|---|
| v1.5.5 (synchron) | 877,563 s |
| v2.0.0-dev (asynchrones I/O) | 45,264 s |
Wie in den anderen Experimenten haben wir die default, speichergesteuerte Read-Ahead-Tiefe genutzt, dieser Lauf war also nicht darauf getunt, das 25-Gbit/s-Netz im Mittel zu sättigen.
Fazit
In diesem Blogbeitrag haben wir die jüngste Arbeit an asynchronem I/O für Parquet- und CSV-Dateien vorgestellt. Der größte Nutzen kommt vom Zugriff auf Remote-Daten, kalte lokale Reads können aber ebenfalls profitieren, wenn auch weniger. Als Nächstes planen wir async Reads für JSON und DuckDB-native Dateien, die zwei anderen Formate, die für DuckDB-Core am relevantesten sind. Formate, die in Out-of-Tree-Erweiterungen leben, stehen noch nicht auf der Roadmap. Wir werden außerdem io_uring untersuchen, Linux’ asynchrone I/O-Schnittstelle, die System-Call-Overhead und die Zahl der auf I/O blockierten Threads senken könnte. Wenn sich das in der Praxis lohnt, integrieren wir es in DuckDB. Wichtig: Jede der in DuckDB unterstützten Data-Lake-Lösungen kann bereits automatisch von asynchronem I/O profitieren, solange das zugrunde liegende Datenformat Parquet ist (oder CSV, wenn Sie mutig genug sind).