2021-10-13

Windowing in DuckDB

Richard Wesley

Window-Funktionen (mit der Klausel OVER) sind wichtige Werkzeuge für die Analyse von Datenreihen, können aber langsam sein, wenn sie nicht sorgfältig implementiert sind. In diesem Beitrag schauen wir uns an, wie DuckDB Windowing umsetzt. Wir sehen auch, wie DuckDB seine Aggregate-Architektur nutzt, um nützliche gleitende Aggregationen wie gleitende Interquartilsabstände (IQRs) zu berechnen.

Jenseits von Mengen

Das ursprüngliche Relationenmodell, das Codd in den 1970er Jahren entwickelte, behandelte Relationen als ungeordnete Mengen von Tupeln. Das war praktisch für theoretische Informatik, ignorierte aber, wie Menschen mit physischen Analogien denken (das Modell des „verkörperten Gehirns“ aus der Neurowissenschaft). Menschen ordnen Daten natürlich, um sie zu verstehen und mit ihnen zu arbeiten. SQL nutzt dafür die Klausel SELECT für das horizontale Layout und ORDER BY für das vertikale.

Die Ordnungen, die Menschen Daten geben, sind oft mehr als neurologische Krücken. Zeit etwa legt eine natürliche Ordnung auf Messungen, und starke Ausschläge können selbst wichtige Daten sein oder anzeigen, dass die Daten durch Glätten bereinigt werden müssen. Trends können vorhanden sein, oder relative Änderungen sind für die Analyse wichtiger als Rohwerte. Um solche Fragen zu beantworten, führte SQL 2003 analytische (oder Window-) Funktionen ein.

Window-Funktionen

Windowing zerlegt eine Relation in unabhängige Partitionen, ordnet diese Partitionen und definiert dann verschiedene Funktionen, die für jede Zeile anhand der benachbarten Werte berechnet werden können. Dazu gehören alle Aggregatfunktionen (etwa sum und avg) sowie einige window-spezifische Funktionen (etwa rank() und nth_value(<expression>, <N>)).

Manche Window-Funktionen hängen nur von der Partitionsgrenze und der Ordnung ab, einige (einschließlich aller Aggregationen) nutzen zusätzlich einen Frame. Frames werden als Anzahl von Zeilen auf beiden Seiten (preceding oder following) der aktuellen Zeile angegeben. Die Distanz kann als Anzahl von rows oder als range von Werten anhand des Ordnungswerts der Partition und einer Distanz angegeben werden.

The Window Computation Environment

Framing ist der verwirrendste Teil der Windowing-Umgebung, schauen wir uns daher ein sehr einfaches Beispiel an und ignorieren Partitionierung und Ordnung zunächst.

SELECT points,
sum(points) OVER (
ROWS BETWEEN 1 PRECEDING
AND 1 FOLLOWING) AS we
FROM results;

Diese Abfrage berechnet die sum jedes Punkts und der Punkte zu beiden Seiten:

Moving sum of three values

Am Rand der Partition werden nur zwei Werte addiert.

Beispiel Stromerzeugung

Nun ein konkretes Beispiel einer Window-Funktionsabfrage. Angenommen, wir haben Erzeugungsdaten eines Kraftwerks:

Plant Date MWh
Boston 2019-01-02 564337
Boston 2019-01-03 507405
Boston 2019-01-04 528523
Boston 2019-01-05 469538
Boston 2019-01-06 474163
Boston 2019-01-07 507213
Boston 2019-01-08 613040
Boston 2019-01-09 582588
Boston 2019-01-10 499506
Boston 2019-01-11 482014
Boston 2019-01-12 486134
Boston 2019-01-13 531518
Worcester 2019-01-02 118860
Worcester 2019-01-03 101977
Worcester 2019-01-04 106054
Worcester 2019-01-05 92182
Worcester 2019-01-06 94492
Worcester 2019-01-07 99932
Worcester 2019-01-08 118854
Worcester 2019-01-09 113506
Worcester 2019-01-10 96644
Worcester 2019-01-11 93806
Worcester 2019-01-12 98963
Worcester 2019-01-13 107170

Die Daten sind verrauscht, daher wollen wir für jedes Kraftwerk einen 7-Tage-gleitenden Mittelwert berechnen. Dafür können wir diese Window-Abfrage nutzen:

SELECT "Plant", "Date",
avg("MWh") OVER (
PARTITION BY "Plant"
ORDER BY "Date" ASC
RANGE BETWEEN INTERVAL 3 DAYS PRECEDING
AND INTERVAL 3 DAYS FOLLOWING)
AS "MWh 7-day Moving Average"
FROM "Generation History"
ORDER BY 1, 2;

Diese Abfrage berechnet den siebentägigen gleitenden Mittelwert der erzeugten Energie jedes Kraftwerks an jedem Tag. Die Klausel OVER ist die SQL-Art, eine Funktion in einem Window zu berechnen. Sie partitioniert die Daten nach Plant (um die Kraftwerke zu trennen), ordnet jede Partition nach Date (um die Energiemessungen nebeneinanderzulegen) und nutzt einen RANGE-Frame von drei Tagen zu beiden Seiten jedes Tages für avg (um fehlende Tage abzufangen). Hier das Ergebnis:

Plant Date MWh 7-day
Moving Average
Boston 2019-01-02 517450.75
Boston 2019-01-03 508793.20
Boston 2019-01-04 508529.83
Boston 2019-01-05 523459.85
Boston 2019-01-06 526067.14
Boston 2019-01-07 524938.71
Boston 2019-01-08 518294.57
Boston 2019-01-09 520665.42
Boston 2019-01-10 528859.00
Boston 2019-01-11 532466.66
Boston 2019-01-12 516352.00
Boston 2019-01-13 499793.00
Worcester 2019-01-02 104768.25
Worcester 2019-01-03 102713.00
Worcester 2019-01-04 102249.50
Worcester 2019-01-05 104621.57
Worcester 2019-01-06 103856.71
Worcester 2019-01-07 103094.85
Worcester 2019-01-08 101345.14
Worcester 2019-01-09 102313.85
Worcester 2019-01-10 104125.00
Worcester 2019-01-11 104823.83
Worcester 2019-01-12 102017.80
Worcester 2019-01-13 99145.75

Sie können mehrere verschiedene OVER-Klauseln in demselben SELECT anfordern, jede wird getrennt berechnet. Häufig wollen Sie dasselbe Window für mehrere Funktionen nutzen, dann können Sie mit einer WINDOW-Klausel ein benanntes Window definieren:

SELECT "Plant", "Date",
avg("MWh") OVER seven AS "MWh 7-day Moving Average"
FROM "Generation History"
WINDOW seven AS (
PARTITION BY "Plant"
ORDER BY "Date" ASC
RANGE BETWEEN INTERVAL 3 DAYS PRECEDING
AND INTERVAL 3 DAYS FOLLOWING)
ORDER BY 1, 2;

Das wäre nützlich, wenn man zusätzlich das 7-Tage-gleitende min und max haben wollte, um die Grenzen der Daten zu zeigen.

Unter den Federn

Das ist eine lange Liste komplizierter Funktionalität! Damit das alles relativ schnell läuft, gehören viele Teile dazu, schauen wir uns an, wie sie in DuckDB umgesetzt sind.

Pipeline Breaking

Als Erstes fällt auf, dass Windowing ein „Pipeline Breaker“ ist. Der Operator Window muss alle Eingaben lesen, bevor er eine Funktion berechnen kann. Gibt es einen anderen Weg, etwas zu berechnen, kann eine andere Technik deutlich schneller sein.

Eine häufige analytische Aufgabe ist, den letzten Wert in einer Gruppe zu finden. Angenommen, wir wollen die zuletzt aufgezeichnete Leistung jedes Kraftwerks. Verlockend ist, die Window-Funktion rank() mit umgekehrter Sortierung zu nutzen:

SELECT "Plant", "MWh"
FROM (
SELECT "Plant", "MWh",
rank() OVER (
PARTITION BY "Plant"
ORDER BY "Date" DESC) AS r
FROM table) t
WHERE r = 1;

Das erfordert jedoch, die gesamte Tabelle zu materialisieren, zu partitionieren, die Partitionen zu sortieren und dann eine einzelne Zeile aus diesen Partitionen zu ziehen. Viel schneller ist ein Self-Join, der die Tabelle auf den letzten (max) Wert des Felds DATE filtert:

SELECT table."Plant", "MWh"
FROM table,
(SELECT "Plant", max("Date") AS "Date"
FROM table GROUP BY 1) lasts
WHERE table."Plant" = lasts."Plant"
AND table."Date" = lasts."Date";

Diese Join-Abfrage braucht zwei Scans der Tabelle, aber das einzige materialisierte Datum ist die Filtertabelle (die wahrscheinlich viel kleiner ist als die Originaltabelle), und es wird gar nicht sortiert.

Diese Art von Abfrage tauchte in einem Nutzerblog auf, und wir fanden, dass die Join-Abfrage auf ihrem Datensatz über 20-mal schneller war:

Window takes 13 seconds, Join takes half a second

Natürlich erfordern die meisten analytischen Aufgaben mit Windowing doch den Operator Window, und DuckDB nutzt eine Reihe von Techniken, um die Leistung so hoch wie möglich zu halten.

Partitionierung und Sortierung

Früher wurde Windowing umgesetzt, indem nach Partition und Ordnungsfeldern sortiert und dann die Partitionsgrenzen gefunden wurden. Das ist ressourcenintensiv, weil die gesamte Relation sortiert werden muss und Sortieren O(N log N) in der Größe der Relation ist. Glücklicherweise gibt es schnellere Wege für diesen Schritt.

Um den Ressourcenverbrauch zu senken, nutzt DuckDB das Partitionierungsschema aus Leis et al., Efficient Processing of Window Functions in Analytical SQL Queries, und zerlegt die Partitionen in 1024 Chunks mit O(N)-Hashing. Die Chunks müssen trotzdem nach allen Feldern sortiert werden, weil es Hash-Kollisionen geben kann, aber jede Partition kann jetzt 1024-mal kleiner sein, was die Laufzeit deutlich senkt. Außerdem können die Partitionen leicht extrahiert und parallel verarbeitet werden.

Sortieren in DuckDB hat kürzlich einen großen Leistungsschub bekommen, zusammen mit der Fähigkeit, Partitionen zu verarbeiten, die größer als der Speicher sind. Diese Funktionalität wurde auch in den Operator Window übernommen, was im Last-in-Group-Beispiel 33 % Verbesserung brachte:

Window takes X seconds, Join takes half a second

Als letzte Optimierung sammelt DuckDB Funktionen mit derselben Partitionierung und Ordnung, auch wenn Sie mehrere Window-Funktionen anfordern, und teilt das Datenlayout zwischen diesen Funktionen.

Aggregation

Die meisten allgemeinen Window-Funktionen sind einfach zu berechnen, aber windowierte Aggregatfunktionen können teuer sein, weil sie für jede Zeile mehrere Werte betrachten müssen. Oft müssen sie denselben Wert mehrfach betrachten oder wiederholt eine große Zahl von Werten ansehen, daher wurden über die Jahre mehrere Ansätze zur Leistungssteigerung verfolgt.

Naive windowierte Aggregation

Bevor wir erklären, wie DuckDB windowierte Aggregation umsetzt, ein kurzer Umweg über gewöhnliche Aggregationen. Aggregat-„Funktionen“ werden mit drei erforderlichen Operationen und einer optionalen umgesetzt:

Der einfachste Weg, einen einzelnen windowierten Aggregatwert zu berechnen, ist, einen Zustand zu initialisieren, den Zustand mit allen Werten im Window-Frame zu aktualisieren und dann mit finalize den Wert der windowierten Aggregation zu erzeugen. Dieser naive Algorithmus funktioniert immer, ist aber ziemlich ineffizient. Eine laufende Summe addiert zum Beispiel alle Werte vom Beginn der Partition für jede laufende Summe erneut, mit einer Laufzeit von O(N^2).

Um das zu verbessern, fügen manche Datenbanken zusätzliche „Moving-State“-Operationen hinzu, die einzelne Werte inkrementell hinzufügen oder entfernen können. Das reduziert die Berechnung in manchen häufigen Fällen, kann aber nur für bestimmte Aggregationen genutzt werden. Für min funktioniert es zum Beispiel nicht, weil man nicht weiß, ob es mehrere gleiche Minima gibt. Und wenn sich die Frame-Grenzen stark bewegen, kann es immer noch zu O(N^2) degenerieren.

Segment-Tree-Aggregation

Statt mehr Funktionen zu ergänzen, nutzt DuckDB den Segment-Tree-Ansatz aus Leis et al. Dabei wird ein Baum über die gesamte Partition gebaut, mit den aggregierten Werten unten. Werte werden in Zustände an Knoten darüber kombiniert, bis es eine einzelne Wurzel gibt:

Segment Tree for sum aggregation

Um einen Wert zu berechnen, erzeugt der Algorithmus Zustände für die unregelmäßigen Enden des Frames, kombiniert Zustände im Baum über den Werten im Frame und finalisiert das Ergebnis aus dem letzten verbliebenen Zustand. Im Beispiel oben (Abbildung 5 aus Leis et al.) müssen nur drei Werte statt 7 addiert werden. Diese Technik funktioniert für alle kombinierbaren Aggregationen.

Allgemeine windowierte Aggregation

Der größte Nachteil von Segment Trees ist die Verwaltung einer potenziell großen Zahl von Zwischenzuständen. Für die einfachen Zustände standardmäßiger distributiver Aggregationen wie sum ist das kein Problem, weil die Zustände klein sind, der Baum die Zahl der Zustände logarithmisch niedrig hält und der Zustand zur Berechnung jedes Werts ebenfalls günstig ist.

Für manche Aggregationen ist der Zustand jedoch nicht klein. Typischerweise sind das sogenannte holistische Aggregationen, bei denen der Wert von allen Werten des Frames abhängt. Beispiele sind mode und quantile, bei denen jeder Zustand eine Kopie aller bisher gesehenen Werte enthalten kann. Segment Trees können genutzt werden, um gleitende Versionen jeder kombinierbaren Aggregation umzusetzen, das kann für große, komplexe Zustände aber teuer sein – und das war nicht das ursprüngliche Ziel des Algorithmus.

Um dieses Problem zu lösen, nutzen wir den Ansatz aus Wesley und Xu, Incremental Computation of Common Windowed Holistic Aggregates, der Segment Trees auf aggregatspezifische Datenstrukturen verallgemeinert. Die Aggregation kann eine fünfte optionale window-Operation definieren, der die Unterseite des Baums und die Grenzen des aktuellen und vorherigen Frames übergeben werden. Die Aggregation kann dann eine passende Datenstruktur für ihre Implementierung anlegen.

Die Funktion mode hält zum Beispiel eine Hash-Tabelle von Zählern, die sie effizient aktualisieren kann, und quantile hält eine teilweise sortierte Liste von Frame-Indizes. Außerdem können die quantile-Funktionen ein Array von Quantilwerten entgegennehmen, was die Leistung weiter steigert, indem die teilweise geordneten Ergebnisse zwischen den verschiedenen Quantilwerten geteilt werden.

Weil diese Aggregationen in einem Windowing-Kontext nutzbar sind, lässt sich das gleitende-Mittelwert-Beispiel oben leicht zu einem gleitenden Interquartilsabstand umbauen:

SELECT "Plant", "Date",
quantile_cont("MWh", [0.25, 0.5, 0.75]) OVER seven
AS "MWh 7-day Moving IQR"
FROM "Generation History"
WINDOW seven AS (
PARTITION BY "Plant"
ORDER BY "Date" ASC
RANGE BETWEEN INTERVAL 3 DAYS PRECEDING
AND INTERVAL 3 DAYS FOLLOWING)
ORDER BY 1, 2;

Gleitende Quantile wie diese sind robuster gegenüber Ausreißern, was sie zu einem wertvollen Werkzeug für die Analyse von Datenreihen macht, in den meisten Datenbanksystemen sind sie aber nicht allgemein umgesetzt. Es gibt Ansätze, die in manchen Query-Engines genutzt werden können, aber ohne eine allgemeine Architektur für gleitende Aggregationen können diese Lösungen unnatürlich oder komplex sein. DuckDBs Implementierung nutzt die Standard-Window-Notation, Sie müssen also keine neue Syntax lernen oder die Daten in ein anderes Werkzeug ziehen.

Ordered-Set-Aggregationen

Window-Funktionen werden oft eng mit einigen speziellen „Ordered-Set-Aggregationen“ des SQL-Standards verbunden. Manche Datenbanken setzen diese Funktionen mit dem Operator Window um, das ist aber eher ineffizient, weil das Sortieren der Daten (eine O(N log N)-Operation) nicht nötig ist – es reicht, Hoares O(N)- FIND-Algorithmus, wie er in der STL- std::nth_element genutzt wird. DuckDB übersetzt diese Ordered-Set-Aggregationen auf die schnelleren regulären Aggregatfunktionen quantile_cont, quantile_disc und mode und vermeidet so Windowing vollständig.

Erweiterungen

Diese Architektur bedeutet auch, dass jede neue Aggregation, die wir ergänzen, von der bestehenden Windowing-Infrastruktur profitieren kann. DuckDB ist ein Open-Source-Projekt, und wir freuen uns über Einreichungen nützlicher Aggregatfunktionen – oder Sie können eigene domänenspezifische in Ihrem Fork anlegen. Irgendwann hoffen wir auf eine UDF-Architektur, die Plug-in-Aggregationen erlaubt, und die Einfachheit und Kraft der Schnittstelle wird diesen Plugins die notationelle Einfachheit und Laufzeitleistung der internen Funktionen geben.

Fazit

DuckDBs Windowing-Implementierung nutzt verschiedene Techniken, um zu beschleunigen, was der langsamste Teil einer analytischen Abfrage sein kann. Sie ist gut in das Sortier-Subsystem und die Aggregate-Architektur integriert, was fortgeschrittene gleitende Aggregationen natürlich und effizient ausdrückbar macht.

DuckDB ist ein freies und quelloffenes Datenbankmanagementsystem (MIT-Lizenz). Es will das SQLite für Analysen sein und bietet ein schnelles, effizientes Datenbanksystem ohne externe Abhängigkeiten. Es ist nicht nur für Python verfügbar, sondern auch für C/C++, R, Java und mehr.