Waarom DuckDB 2.0 sneller is

DuckDB 2.0 komt dit najaar en de alpha is al beschikbaar. Ik heb de interessante functies getest op mijn eigen laptop en tegen S3, om te zien wat er daadwerkelijk verandert voor mensen die tabellen en pipelines bouwen, in plaats van database-engines.

Ja, DuckDB 2.0 is sneller. Maar om die snelheidswinst te behalen, moet je begrijpen hoe je data is vormgegeven en soms hoe je deze moet modelleren. In dit artikel bespreek ik de drie functies die volgens mij het meest belangrijk zijn, inclusief de resultaten die ik heb behaald, plus een paar verborgen parels uit de commit-logs.

Let op: alle cijfers hieronder zijn gemeten op één machine (een M5-laptop) met mijn thuisinternet, wat beide versies ongeveer gelijk vertraagt. Test dit zelf voordat je de cijfers citeert.

Async I/O: Query's via AWS S3 zijn veel sneller

Dit is mijn favoriete update, omdat er niets aan je query verandert. Hieronder staat een query die een Parquet-bestand van 2,2 GB op S3 leest (Stack Overflow-stemmen, 228 miljoen rijen, 2268 row groups) en de stemmen per type telt. Er wordt één van de vier kolommen gelezen, ongeveer 230 MB.

CREATE SECRET s3 (TYPE s3, PROVIDER credential_chain, REGION 'us-east-1');
SET enable_external_file_cache = false;   -- zodat elke run echt S3 raakt
SELECT VoteTypeId, count(*) AS n
FROM read_parquet('s3://us-prd-motherduck-open-datasets/stackoverflow/parquet/2023-05/votes.parquet')
GROUP BY ALL ORDER BY 1;

Resultaten (zelfde query, zelfde laptop):

VersieTijd
DuckDB 1.5.518,8 s
DuckDB 2.0 alpha7,7 s

Kanttekening: Omdat ik dit vanaf mijn thuisinternet naar us-east-1 doe, zijn beide tijden traag. Verwacht hogere snelheden als je dit uitvoert vanuit cloud-compute.

Hoe werkt dit?

Het bestand is opgedeeld in 2268 row groups van ongeveer 122.000 rijen. Voor elke row group downloadt DuckDB de bytes, decodeert de Parquet, telt de stemmen per type en voegt aan het einde de deelresultaten samen. Er zijn twee soorten werk: wachten op het netwerk en rekenwerk op de CPU.

In versie 1.5.5 voert elke worker (18 in totaal) beide taken na elkaar uit: downloaden, wachten, decoderen, downloaden, wachten. Terwijl een worker wacht, is de CPU inactief; terwijl hij decodeert, wordt er niets gedownload. Er zijn nooit meer dan 18 downloads tegelijk gaande.

In versie 2.0 is er een aparte pool van threads die uitsluitend downloadt. Deze houdt tientallen row groups "in flight" en parkeert de bytes in een buffer. De workers hoeven alleen maar te decoderen, en er is altijd een row group klaar voor hen. Netwerk en CPU zijn nu gelijktijdig actief.

Deze functionaliteit wordt aangestuurd door één instelling: readaheaddepth. Dit bepaalt hoeveel row groups de download-pool vooruit mag ophalen ten opzichte van de workers. De standaardwaarde is -1 (automatisch, gebaseerd op het aantal threads), dus async I/O staat standaard aan. Als je dit op 0 zet, krijg je het gedrag van versie 1.5 terug.

S3 leesprestaties vergelijking:

DatasetDuckDB 1.5.5DuckDB 2.0 alpha
Eén Parquet-bestand (2,2 GB), één kolom18,8 s7,7 s
23 grote Parquet-bestanden (13,6 GB), één kolom11,8 s3,9 s
Eén gewoon CSV-bestand (1,7 GB)116 s55 s
30 kleine Parquet-bestanden (~1 MB per stuk)3,7 s3,3 s

Over de kleine bestanden: hier is geen significant verschil, omdat de tijd wordt bepaald door de round trips per bestand (eerst de footer, dan de data), wat vooruit lezen niet kan oplossen. Het opslaan van een data lake als duizenden bestanden van 1 MB is bovendien slecht beheer; versie 2.0 lost dit fundamentele probleem niet op.

Samenvattend: Het lezen van data via S3 is in 2.0 twee tot drie keer sneller zonder dat je queries hoeven te worden aangepast, dankzij de separate download-pool.

---

Recursieve CTE's: Een boost voor diepe ouder/kind-datasets

Het DuckDB-team heeft de recursive CTE-engine herschreven en claimt een versnelling van 40x bij grafiekbereikbaarheid (graph reachability).

Een recursieve CTE is in feite een loop over een tabel. Neem bijvoorbeeld een tabel met werknemers met twee kolommen: manager en employee. Je wilt weten wie aan wie rapporteert. Je begint bij de CEO (bijv. Ana). Ronde één zoekt iedereen die aan Ana rapporteert. Ronde twee zoekt iedereen die aan die mensen rapporteert, enzovoort, tot er niemand nieuws wordt gevonden.

De query ziet er als volgt uit:

WITH RECURSIVE team(person) AS (
  SELECT 'Ana'
  UNION
  SELECT e.employee FROM team t JOIN employees e ON e.manager = t.person
)
SELECT count(*) FROM team;

Dit type data (organogrammen, mappenstructuren, stuklijsten, reply-threads, data lineage, git-historie) bestaat vaak uit dezelfde twee kolommen: ouder en kind. Het enige verschil is de diepte.

Het probleem in 1.5.5

In versie 1.5.5 las de engine elke ronde de volledige tabel opnieuw om het volgende niveau te vinden. Acht niveaus betekenden acht volledige scans. Duizenden niveaus betekenden duizenden volledige scans van dezelfde tabel.

In versie 2.0 wordt de tabel slechts één keer gelezen. Er wordt eenmalig een lookup op de ouder-kolom gebouwd, en elke ronde zoekt de engine alleen de paar rijen op die zojuist zijn gevonden. De kosten zijn nu gekoppeld aan de rijen die je daadwerkelijk raakt, niet aan het aantal rondes vermenigvuldigd met de tabelgrootte.

Dit effect is het grootst bij git-historie, waar elke commit naar zijn ouder wijst. Voor een repo met 20.000 commits is het verschil enorm:

Ancestry walk (20.000 commits):

VersieTijd
DuckDB 1.5.51,8 s tot 16 s (variërend per run)
DuckDB 2.0 alpha0,10 s (consistent)

Samenvattend: Als je diepe ouder/kind-ketens doorloopt, maakt 2.0 van een taak die je voorheen naar een graaf-database moest pushen, een normale query. Bij ondiepe hiërarchieën (zoals een klein organogram) zul je weinig verschil merken.

---

VARIANT: Kleiner en sneller dan JSON

VARIANT is nu een eersteklas datatype in DuckDB. Het belangrijkste concept hier is shredding.

Shredding betekent het volgende: wanneer DuckDB een row group naar schijf schrijft, analyseert het de JSON-kolom en zoekt het naar velden die in de meeste rijen voorkomen met hetzelfde type waarde.

Voorbeeld van een event: {"type": "purchase", "user": {"id": 42, "country": "FR", "premium": true}, "props": {"amount": 12.5, "currency": "EUR", "items": 2}, "tags": ["a", "b"]}

In dit geval is type altijd tekst, user.id altijd een getal en props.amount altijd een decimaal getal. Deze velden worden intern uitgebroken naar hun eigen echte kolommen. Zeldzame velden of velden met wisselende types blijven samen in een binaire restgroep. Het consistente deel van je JSON wordt dus opgeslagen als een normale tabel, en alleen het "rommelige" deel als een blob.

Prestaties: JSON vs VARIANT

Ik heb vijf miljoen events opgeslagen op drie manieren: als JSON-string, als VARIANT en als een normale tabel met getypeerde kolommen.

Opslag en query-tijden:

MetriekJSON stringVARIANT (2.0 alpha)VARIANT (1.5.5)Getypeerde kolommen
Op schijf224 MB85 MB81 MB45 MB
Q1: Filter op 2 velden366 ms63 ms4,96 s52 ms
Q2: Som per land408 ms61 ms5,28 s51 ms
Q3: List lookup357 ms1,96 s4,65 s67 ms

Belangrijkste conclusies:

  • VARIANT is 2,7 keer kleiner dan een JSON-string.
  • Voor queries op shredded velden (filters of sommen) is VARIANT ongeveer 6 keer sneller dan het parsen van JSON-tekst en komt het binnen 20% van de snelheid van getypeerde kolommen.
  • Ten opzichte van VARIANT in 1.5.5 is het 78 keer sneller, omdat 1.5.5 wel het type had, maar niet de shredding.
  • De list-query is de uitzondering; het casten van een VARIANT-lijst naar VARCHAR[] is in deze alpha nog traag.

Gouden regel voor modellering: Modelleer wat je weet. Velden die elke query raakt, verdienen echte kolommen. Je kunt een veld eenvoudig promoten:

ALTER TABLE ev ADD COLUMN type VARCHAR;
UPDATE ev SET type = payload.type::VARCHAR;

Samenvattend: Als je events een consistente set velden met consistente types delen, bewaar ze dan als VARIANT in plaats van als JSON-string. Je bespaart opslag en de query's op velden werken bijna als echte kolommen.

---

Sneloverzicht van de release en de commit-log

Nieuwe functies en verbeteringen

  • Triggers: Tabellen kunnen nu zelf SQL uitvoeren wanneer rijen wijzigen. Je kunt bijvoorbeeld een trigger maken die wijzigingen in prijzen direct in een historietabel schrijft.

``sql CREATE TRIGGER pricehistory AFTER UPDATE ON prices REFERENCING OLD TABLE AS beforerows NEW TABLE AS afterrows FOR EACH STATEMENT INSERT INTO pricehistory (id, oldprice, newprice) SELECT a.id, b.price, a.price FROM beforerows b JOIN afterrows a USING (id); ``

  • Nested schemas: Ondersteuning voor schema's binnen schema's, bijv. CREATE SCHEMA finance.reports;.
  • DML in een CTE: Je kunt nu DELETE ... RETURNING gebruiken binnen een WITH-statement om rijen atomair te verplaatsen naar een andere tabel.

``sql WITH moved AS MATERIALIZED (DELETE FROM staging RETURNING ) INSERT INTO prod SELECT FROM moved; ``

  • Spark compatibiliteitsmodus: Gebruik SET dialectcompatibilitymode = 'spark' om pipelines makkelijker van Spark SQL naar DuckDB te migreren.
  • External file cache spill: Met SET externalfilecache_spill = true worden gecachte remote file blocks naar de temp-directory geschreven in plaats van opnieuw gedownload bij uitwijzing uit het geheugen.
  • CLI verbeteringen: De CLI heeft nu een SQL-formatter (duckdb -format), een querybare historie (.history), en commando's als .about en .manual <function>.
  • JSON timestamps: read_json detecteert ISO-8601 timestamps met een offset nu correct als TIMESTAMPTZ.
  • Connection scoping: CREATE SECRET S IN CONNECTION (...) beperkt een geheim tot één specifieke verbinding.
  • Quack: Het client-server protocol van DuckDB bereikt met deze release versie 1.0.

Probeer het uit

De alpha is beschikbaar via de CLI met één regel:

curl https://install.duckdb.org | D cubed_VERSION=alpha bash
~/.duckdb/cli/latest/duckdb -c "SELECT version()"

Andere clients zijn beschikbaar op de DuckDB installatiepagina. MotherDuck zal 2.0 ondersteunen kort voor de officiële release.