2025-02-19
AsOf-Joins planen
Richard Wesley
„I love it when a plan comes together.“
— Hannibal Smith, The A-Team
Einleitung
AsOf-Joins sind eine sehr nützliche Art von Operation für temporale Analytik. Wie der Name sagt, sind sie eine Art Lookup, wenn Sie eine Tabelle von Werten haben, die sich über die Zeit ändern, und den neuesten Wert zu einem anderen Satz von Zeitpunkten nachschlagen wollen. Anders gesagt erlauben sie die Frage „Was war der Wert der Eigenschaft zu diesem Zeitpunkt?“
DuckDB hat vor etwa 18 Monaten AsOf-Joins ergänzt, und in diesem Beitrag können Sie ihre Semantik nachlesen.
Was ist der Plan?
In jenem früheren Beitrag habe ich erklärt, warum wir einen eigenen Operator und eine eigene Syntax für AsOf-Joins haben, obwohl man sie in klassischem SQL implementieren kann. Die Superkraft von SQL ist, dass es deklarativ ist: Sie sagen uns was Sie wollen, und wir finden ein effizientes wie heraus. Indem Sie sagen können, dass Sie einen AsOf-Join wollen, können wir darüber nachdenken, wie Sie schneller zu Ergebnissen kommen!
Trotzdem muss der AsOf-Operator viel Arbeit tun. Nämlich:
- Alle Daten der rechten (Lookup-)Tabelle lesen
- Sie nach allen Gleichheitsbedingungen partitionieren
- Sie nach der Ungleichheitsbedingung sortieren
- Den Prozess für die linke (Probe-)Tabelle wiederholen
- Einen Merge Join auf den zwei Tabellen machen, der nur den „neuesten“ Wert zurückgibt
Das ist viel Datenbewegung! Außerdem können wir, wenn eine der Tabellen groß ist, den Speicher überschreiten und auf Platte spulen, was die Operation noch weiter verlangsamt. Trotzdem ist sie, wie wir sehen werden, deutlich schneller als die reine SQL-Implementierung.
Das ist so eine Last, dass viele Datenbanken, die AsOf-Joins unterstützen, verlangen, dass die rechte Tabelle nach allen Keys, nach denen Sie joinen könnten, partitioniert und geordnet ist. Das passt nicht gut zu DuckDBs „Friendly SQL“-Ansatz, deshalb müssen wir das (vorerst) jedes Mal tun.
Klein halten
Es gibt einen sehr häufigen Fall für AsOf, bei dem die linke Tabelle klein ist. Angenommen, Sie haben ein Jahr Preisdaten, in hoher Granularität aufgezeichnet (z. B. Sekundenbruchteile), wollen aber nur eine kleine Zahl (sagen wir 20) Werte nachschlagen, die die Zeiten sind, zu denen Sie tatsächlich gekauft oder verkauft haben?
Die Preistabelle könnte auf Hunderte von Millionen, wenn nicht Milliarden Zeilen hinauslaufen, und allein das Sortieren braucht viel Zeit und Speicher. Angesichts dessen, wie teuer das ist, könnte man sich fragen, ob es einen Weg gibt, all dieses Sortieren zu vermeiden? Glücklicherweise lautet die Antwort ja!
Einfache Joins
Angenommen, wir tauschen die Seiten des Joins, bauen die alte linke Seite als kleine rechte Tabelle und streamen die riesige Tabelle durch die linke Seite des Joins. Wir könnten die AsOf-Bedingungen für den Join nutzen und hoffentlich einen Weg finden, die älteren Matches zu verwerfen (wir wollen nur das neueste Match behalten). Das würde sehr wenig Speicher nutzen, und das Streaming könnte stark parallelisiert werden.
Es gibt zwei streaming physische Join-Operatoren, die wir dafür nutzen könnten:
- Nested Loop Join – Wörtlich das, wonach es klingt: über jeden linken Block und die rechte Tabelle loopen und auf Matches prüfen;
- Piecewise Merge Join – Ein kniffliger Join für eine Ungleichheitsbedingung, der die rechte Seite und jeden linken Block sortiert, bevor er mergt, um Matches zu finden;
Wir können beide ausprobieren, sobald wir einen Weg haben, die Duplikate zu eliminieren.
Eine Sache, die man im Blick behalten sollte: Beide sind N^2-Algorithmen,
es gibt also eine Grenze, wie groß „klein“ sein kann.
Gruppierung
Wenn Sie lange genug um Datenbanken herum sind,
wissen Sie, dass die Phrase „die Duplikate eliminieren“ GROUP BY bedeutet!
Um die Duplikate zu eliminieren, wollen wir also einen Aggregationsoperator an die Ausgabe hängen.
Der knifflige Teil: Wir wollen nur die gematchten Werte behalten, die die „größten“ Zeiten haben.
Glücklicherweise hat DuckDB ein Paar Aggregatfunktionen, die genau das tun:
arg_max und
arg_min
(auch max_by und min_by genannt).
Das kümmert sich um die Felder aus der Lookup-Tabelle, aber was ist mit den Feldern aus der kleinen Tabelle?
Nun, diese Werte sind alle gleich, wir können also einfach die Aggregatfunktion first für sie nutzen.
Streaming Window
Aber wonach sollen wir gruppieren?
Man könnte versucht sein, nach den nachgeschlagenen Zeiten zu gruppieren,
das könnte aber problematisch sein, wenn es doppelte Lookup-Zeiten gibt
(nur eine der Zeilen würde zurückgegeben!).
Stattdessen brauchen wir einen eindeutigen Identifikator für jede nachgeschlagene Zeile.
Der einfachste Weg ist, den
Streaming-Window-Operator
mit der Window-Funktion row_number() zu nutzen.
Dann gruppieren wir nach dieser Zeilennummer.
Zusammenkommen
Das klingt alles gut, aber wie funktioniert es in der Praxis?
Wie groß kann „klein“ werden?
Um das zu beantworten, habe ich eine Reihe von Benchmarks ausgeführt, die kleine Tabellen gegen große joinen.
Die Tabellen heißen prices und times:
CREATE OR REPLACE TABLE prices_{prices_size} AS SELECT r AS id, '2021-01-01T00:00:00'::TIMESTAMP + INTERVAL (random() * 365 * 24 * 60 * 60) SECOND AS time, (random() * 100000)::INTEGER AS price, FROM range({prices_size}) tbl(r);
CREATE OR REPLACE TABLE times_{times_size} AS SELECT r AS id, '2021-01-01'::TIMESTAMP + INTERVAL ((random() * 365 * 24 * 60 * 60)::INTEGER) SECONDS AS probe FROM range({times_size}) tbl(r);Dann habe ich eine Benchmark-Query ausgeführt:
SELECT count(*)FROM ( SELECT t.probe, p.price FROM times_{times_size} t ASOF JOIN prices_{prices_size} p ON t.probe >= p.time ) t;für eine Matrix der folgenden Werte:
- Prices – 100K bis 1B Zeilen in Schritten von 10×;
- Time – 1 bis 2048 Zeilen in Schritten von 2× (bis es zu langsam wurde);
- Threads – 36, 18 und 9;
Hier die Ergebnisse:
Wie Sie sehen, heißt die quadratische Natur der Joins, dass „klein“ „<= 64“ bedeutet. Das ist ziemlich klein, aber die Tabelle im ursprünglichen User-Issue hatte nur 21 Werte.
Wir sehen auch, dass die Sortierung durch den Piecewise Merge Join nicht viel zu helfen scheint, deshalb ist der schlichte Nested Loop Join die beste Wahl.
Es ist klar, dass die Performance des Standardoperators bei jeder Größe stabil ist, aber langsam sinkt, wenn die Zahl der Threads steigt. Das ergibt Sinn, weil Sortieren rechenintensiv ist und je weniger Kerne wir zuweisen können, desto länger dauert es.
Wenn Sie mehr mit den Daten spielen wollen, finden Sie die interaktive Visualisierung auf unserer Tableau-Public-Seite.
Speicher
Der Loop-Join-Plan ist bei kleinen Größen klar schneller, aber wie viel Speicher nutzen die beiden Pläne? Das sind die groben Speichermengen, die nötig sind, bevor übermäßiges Paging oder Allokationsfehler auftreten:
| Price-Zeilen | AsOf-Speicher | Loop-Join-Speicher |
|---|---|---|
| 1B | 48 GB | 64 MB |
| 100M | 6 GB | 64 MB |
| 10M | 256 MB | 64 MB |
| 1M | 32 MB | 64 MB |
| 100K | 32 MB | 64 MB |
Anders gesagt braucht der Loop-Join-Plan nur genug Speicher, um die Lookup-Tabelle einzupagen! Ist die Tabelle also groß und Sie haben begrenzten Speicher, ist der Loop-Join-Plan die beste Option, selbst wenn er schmerzhaft langsam ist. Denken Sie nur daran, dass der Loop-Join-Plan mit der Geschwindigkeit des Standardoperators unter Paging konkurrieren muss, und der kann ab einem bestimmten Punkt immer noch schneller sein.
Backup-Pläne
Als Teil des Experiments habe ich auch gemessen, wie die alte SQL-Implementierung performen würde, und sie hat nicht gut abgeschnitten. Auf der 1B-Zeilen-Stufe musste ich sie nach einem Lauf abschneiden, um keine Zeit zu verschwenden:
Hinweis: Die Y-Achse ist hier eine Log-Skala!
Einstellung
Es ist schön, dass wir einen Default-Wert für solche Planentscheidungen liefern, Ihre Ergebnisse können aber, wie man so sagt, abweichen.
Haben Sie mehr Zeit als Speicher, könnte es sich für Sie lohnen, die Loop-Join-Schwelle etwas anzuheben.
Die Schwelle ist eine neue Einstellung namens asof_loop_join_threshold mit einem Default-Wert von 64,
und Sie können sie mit einem PRAGMA-Statement ändern:
PRAGMA asof_loop_join_threshold = 128;Denken Sie aber daran: Das ist eine quadratische Operation, und sie zu hoch zu setzen, könnte eine Sehr Lange Zeit dauern (besonders wenn Sie sie in Altem Entisch ausdrücken!).
Wenn Sie das Feature deaktivieren möchten, können Sie es einfach auf null setzen:
PRAGMA asof_loop_join_threshold = 0;Selbst gebaut
Diese Loop-Join-Plan-Optimierung kommt erst in v1.3, aber wenn Sie heute Probleme haben, können Sie immer Ihre eigene Version so schreiben:
SELECT first(t.probe) AS probe, arg_max(p.price, p.time) AS priceFROM prices pINNER JOIN ( SELECT *, row_number() OVER () AS pk FROM times) tON t.probe >= p.timeGROUP BY pkORDER BY 1;Wenn Sie wissen, dass die Probe-Zeiten eindeutig sind, können Sie das vereinfachen zu:
SELECT t.probe, arg_max(p.price, p.time) AS priceFROM prices pINNER JOIN times tON t.probe >= p.timeGROUP BY 1ORDER BY 1;Zukünftige Arbeit
Das neue AsOf-Loop-Join-Plan-Feature deckt nur eine häufige, aber sehr spezifische Situation ab, und der Standardoperator könnte deutlich effizienter gemacht werden, wenn er wüsste, dass die Daten bereits sortiert sind. Das ist oft der Fall, aber wir haben noch nicht die Fähigkeit, Partitionierung und Ordnung zwischen Operatoren zu verfolgen. Diese Art von Metadaten zu verfolgen, wäre sehr nützlich, um eine große Zahl von Operationen zu beschleunigen, einschließlich Sortierung (!), partitionierter Aggregation, Windowing, AsOf-Joins und Merge Joins. Das ist Arbeit, an der wir sehr interessiert sind – bleiben Sie dran!
Fazit
Mit Entschuldigung an Guido van Rossum: Es gibt meist mehr als einen Weg, etwas zu tun, aber jeder Weg kann radikal unterschiedliche Performance-Eigenschaften haben. Eine der Aufgaben einer relationalen Datenbank mit einer deklarativen Query-Sprache wie SQL ist, intelligente Entscheidungen zwischen den Optionen zu treffen, damit Sie als Nutzer sich auf das Ergebnis konzentrieren können. Hier bei DuckDB freuen wir uns darauf, mehr Wege zu finden, Ihre Queries zu planen, damit Sie sich auf das konzentrieren können, was Sie am besten können!
Anmerkungen
- Die Tests liefen alle auf einem iMac Pro mit einem 2,3-GHz-18-Kern-Intel-Xeon-W-CPU und 128 GB RAM.
- Die rohen Testdaten und Visualisierungen sind in unserem Tableau-Public-Repository verfügbar.
- Das Skript zum Erzeugen der Daten liegt in meinem öffentlichen DuckDB-Tools-Repository.