2024-10-25
Schnelle Top-N-Aggregation und -Filterung mit DuckDB
Alex Monahan
Einführung in Top N
Ein häufiges Muster bei der Datenanalyse ist, nach den Zeilen zu suchen, die in einer bestimmten Metrik am höchsten oder niedrigsten liegen.
Wenn die höchsten oder niedrigsten N Zeilen eines gesamten Datensatzes interessieren, sortieren die Standardklauseln ORDER BY und LIMIT von SQL nach der gewünschten Metrik und liefern nur N Zeilen.
Zum Beispiel mit dem Scale-Factor-1-Datensatz (SF1) des TPC-H-Benchmarks:
INSTALL tpch;LOAD tpch;-- Generate an example TPC-H datasetCALL dbgen(sf = 1);
-- Return the most recent 3 rows by l_shipdateFROM lineitemORDER BY l_shipdate DESCLIMIT 3;| l_orderkey | l_partkey | … | l_shipmode | l_comment |
|---|---|---|---|---|
| 354528 | 6116 | … | wake according to the u | |
| 413956 | 16402 | … | SHIP | usual patterns. carefull |
| 484581 | 10970 | … | TRUCK | ccounts maintain. dogged accounts a |
Das ist nützlich, um schnell die ältesten oder neuesten Werte in einem Datensatz zu holen oder Ausreißer in einer bestimmten Metrik zu finden.
Ein anderer gängiger Ansatz ist, die Min-/Max-Zusammenfassungsstatistiken einer oder mehrerer Spalten abzufragen.
Das findet Ausreißer, aber die Zeile, die den Ausreißer enthält, kann für jede Spalte eine andere sein – die Frage ist also eine andere.
DuckDBs hilfreicher COLUMNS-Ausdruck erlaubt uns, den Maximalwert für alle Spalten zu berechnen.
FROM lineitemSELECT max(COLUMNS(*));Die Queries in diesem Beitrag nutzen ausgiebig DuckDBs
FROM-first-Syntax. Damit lassen sich die KlauselnFROMundSELECTtauschen, und Letztere kann sogar ganz weggelassen werden.
| l_orderkey | l_partkey | … | l_shipmode | l_comment |
|---|---|---|---|---|
| 600000 | 20000 | … | TRUCK | zzle. slyly |
Diese beiden Ansätze können aber nur bestimmte Arten von Fragen beantworten.
Es gibt viele Szenarien, in denen das Ziel ist, die Top-N-Werte innerhalb einer Gruppe zu verstehen.
Im ersten Beispiel oben: Wie berechnen wir die letzten 10 Lieferungen jedes Lieferanten?
Die LIMIT-Klausel von SQL kann das nicht abbilden.
Nennen wir diese Art der Analyse Top N nach Gruppe.
Diese Art der Analyse ist ein gängiges Werkzeug, um neue Datensätze zu erkunden. Anwendungsfälle sind etwa, die neuesten paar Zeilen für jede Gruppe zu holen oder die extremsten paar Werte in einer Gruppe zu finden. Bleiben wir beim Lieferbeispiel: Wir könnten uns die letzten 10 Lieferungen jeder Teilenummer ansehen oder die 5 teuersten Bestellungen pro Kunde finden.
Klassisches Top N nach Gruppe
In den meisten Datenbanken filtert man auf das Top N innerhalb einer Gruppe mit einer Window-Funktion und einem Common Table Expression (CTE). Dieser Ansatz funktioniert auch in DuckDB. Diese Query liefert zum Beispiel die 3 neuesten Lieferungen für jeden Lieferanten:
WITH ranked_lineitem AS ( FROM lineitem SELECT *, row_number() OVER (PARTITION BY l_suppkey ORDER BY l_shipdate DESC) AS my_ranking)FROM ranked_lineitemWHERE my_ranking <= 3;| l_orderkey | l_partkey | l_suppkey | … | l_shipmode | l_comment | my_ranking |
|---|---|---|---|---|---|---|
| 1310688 | 169532 | 7081 | … | RAIL | ully final exc | 1 |
| 910561 | 194561 | 7081 | … | SHIP | ly bold excuses caj | 2 |
| 4406883 | 179529 | 7081 | … | RAIL | tions. furious | 3 |
| 4792742 | 52095 | 7106 | … | RAIL | onic, ironic courts. final deposits sleep | 1 |
| 4010212 | 122081 | 7106 | … | accounts cajole finally ironic instruc | 2 | |
| 1220871 | 94596 | 7106 | … | TRUCK | regular requests above t | 3 |
| … | … | … | … | … | … | … |
In DuckDB lässt sich das mit der QUALIFY-Klausel vereinfachen.
QUALIFY wirkt wie eine WHERE-Klausel, operiert aber speziell auf den Ergebnissen von Window-Funktionen.
Mit dieser Anpassung kann der CTE entfallen, die Ergebnisse bleiben dieselben.
FROM lineitemSELECT *, row_number() OVER (PARTITION BY l_suppkey ORDER BY l_shipdate DESC) AS my_rankingQUALIFY my_ranking <= 3;Das ist durchaus ein gangbarer Weg!
Wo liegen aber die Schwächen?
Obwohl die Query nur an den 3 neuesten Lieferungen interessiert ist, muss sie jede Lieferung sortieren, nur um diese Top 3 zu holen.
Sortieren in DuckDB hat eine Komplexität von O(kn) dank DuckDBs innovativer Radix-Sort-Implementierung, das ist aber immer noch höher als das O(n) von DuckDBs Hash-Aggregat, zum Beispiel.
Sortieren ist außerdem speicherintensiv im Vergleich zur Aggregation.
Top N in DuckDB
DuckDB 1.1 hat eine neue Fähigkeit ergänzt, die Top-N-Berechnungen deutlich vereinfacht und beschleunigt.
Nämlich akzeptieren die Funktionen min, max, min_by und max_by jetzt alle einen optionalen Parameter N.
Ist N größer als 1 (der Default), liefern sie ein Array der Top-Werte.
Als einfaches Beispiel fragen wir die neuesten (Top-3-)Lieferdaten ab:
FROM lineitemSELECT max(l_shipdate, 3) AS top_3_shipdates;| top_3_shipdates |
|---|
| [1998-12-01, 1998-12-01, 1998-12-01] |
Top N nach Spalte in DuckDB
Die Top-N-Auswahl wird noch nützlicher dank erneut des COLUMNS-Ausdrucks – wir können die 3 Top-Werte in jeder Spalte holen.
Nennen wir das eine Top-N-nach-Spalte-Analyse.
Mit gewöhnlichem SQL ist das besonders unübersichtlich!
Man bräuchte eine Subquery oder Window-Funktion für jede einzelne Spalte…
In DuckDB einfach:
FROM lineitemSELECT max(COLUMNS(*), 3) AS "top_3_\0";| top_3_l_orderkey | top_3_l_partkey | … | top_3_l_shipmode | top_3_l_comment |
|---|---|---|---|---|
| [600000, 600000, 599975] | [20000, 20000, 20000] | … | [TRUCK, TRUCK, TRUCK] | [zzle. slyly, zzle. quickly bold a, zzle. pinto beans boost slyly slyly fin] |
Top N nach Gruppe in DuckDB
Ausgestattet mit dem neuen Parameter N: Wie können wir eine Top-N-nach-Gruppe-Analyse beschleunigen?
Wollen Sie gleich zum Endergebnis? Springen Sie gerne voraus!
Wir nutzen drei weitere DuckDB-SQL-Features, um das möglich zu machen:
- Die Funktion
max_by(auch bekannt alsarg_max) - Die Funktion
unnest - Automatisches Packen einer ganzen Zeile in eine
STRUCT-Spalte
Die Funktion max liefert das Maximum (oder jetzt das Max-N!) einer bestimmten Spalte.
Im Gegensatz dazu findet max_by den Maximalwert in einer Spalte und holt dann einen Wert aus derselben Zeile, aber einer anderen Spalte.
Diese Query liefert zum Beispiel die IDs der 3 zuletzt gelieferten Bestellungen für jeden Lieferanten:
FROM lineitemSELECT l_suppkey, max_by(l_orderkey, l_shipdate, 3) AS recent_ordersGROUP BY l_suppkey;| l_suppkey | recent_orders |
|---|---|
| 2992 | [233573, 3597639, 3060227] |
| 8516 | [4675968, 5431174, 4626530] |
| 3205 | [3844610, 4396966, 3405255] |
| 2152 | [1672000, 4209601, 3831138] |
| 1880 | [4852999, 2863747, 1650084] |
| … | … |
max_by ist eine Aggregatfunktion und nutzt damit DuckDBs schnelle Hash-Aggregation statt Sortierung.
Statt nach l_shipdate zu sortieren, scannt max_by den Datensatz nur einmal und merkt sich die N höchsten l_shipdate-Werte.
Dann liefert sie die Bestell-ID, die zu jedem der neuesten Lieferdaten gehört.
Der Radix-Sort in DuckDB muss den Datensatz einmal pro Byte scannen, ein einmaliger Scan bringt also einen deutlichen Speedup.
Sortiert man zum Beispiel nach einer 64-Bit-Ganzzahl, muss der Sortieralgorithmus den Datensatz 8-mal durchlaufen, gegenüber 1-mal mit diesem Ansatz!
Ein einfacher Mikro-Benchmark steht im Abschnitt Performance-Vergleiche.
Diese SQL-Query hat aber ein paar Lücken.
Die Query liefert Ergebnisse als LIST statt als getrennte Zeilen.
Glücklicherweise kann unnest eine LIST in getrennte Zeilen aufteilen:
FROM lineitemSELECT l_suppkey, unnest( max_by(l_orderkey, l_shipdate, 3) ) AS recent_ordersGROUP BY l_suppkey;| l_suppkey | recent_orders |
|---|---|
| 2576 | 930468 |
| 2576 | 2248354 |
| 2576 | 3640711 |
| 5559 | 4022148 |
| 5559 | 1675680 |
| 5559 | 4976259 |
| … | … |
Die nächste Lücke: Es gibt keine einfache Möglichkeit, das zu den zurückgegebenen l_orderkey-Werten gehörige l_shipdate zu sehen.
Diese Query liefert nur eine einzelne Spalte, während eine Top-N-nach-Gruppe-Analyse typischerweise die ganze Zeile braucht.
Glücklicherweise erlaubt DuckDB uns, den gesamten Inhalt einer Zeile so zu referenzieren, als wäre sie nur eine einzelne Spalte!
Indem wir den Namen der Tabelle selbst (hier lineitem) statt des Namens einer Spalte angeben, kann max_by alle Spalten holen.
FROM lineitemSELECT l_suppkey, unnest( max_by(lineitem, l_shipdate, 3) ) AS recent_ordersGROUP BY l_suppkey;| l_suppkey | recent_orders |
|---|---|
| 5411 | {‘l_orderkey’: 2543618, ‘l_partkey’: 105410, ‘l_suppkey’: 5411, … |
| 5411 | {‘l_orderkey’: 580547, ‘l_partkey’: 130384, ‘l_suppkey’: 5411, … |
| 5411 | {‘l_orderkey’: 3908642, ‘l_partkey’: 132897, ‘l_suppkey’: 5411, … |
| 90 | {‘l_orderkey’: 4529697, ‘l_partkey’: 122553, ‘l_suppkey’: 90, … |
| 90 | {‘l_orderkey’: 4473346, ‘l_partkey’: 160089, ‘l_suppkey’: 90, … |
| … | … |
Machen wir das etwas freundlicher, indem wir den STRUCT in getrennte Spalten aufteilen, damit er zu unserem ursprünglichen Datensatz passt.
Die finale Top-N-nach-Gruppe-Query
Ein weiteres Argument an UNNEST teilt das rekursiv in getrennte Spalten auf.
In diesem Fall heißt das: UNNEST läuft zweimal: einmal, um jede LIST in getrennte Zeilen zu wandeln, und dann noch einmal, um jeden STRUCT in getrennte Spalten zu wandeln.
Die Spalte l_suppkey kann auch weggelassen werden, da sie automatisch schon enthalten ist.
FROM lineitemSELECT unnest( max_by(lineitem, l_shipdate, 3), recursive := 1 ) AS recent_ordersGROUP BY l_suppkey;| l_orderkey | l_partkey | l_suppkey | … | l_shipinstruct | l_shipmode | l_comment |
|---|---|---|---|---|---|---|
| 1234726 | 6875 | 6876 | … | COLLECT COD | FOB | cajole carefully slyly fin |
| 2584193 | 51865 | 6876 | … | TAKE BACK RETURN | TRUCK | fully regular deposits at the q |
| 2375524 | 26875 | 6876 | … | DELIVER IN PERSON | AIR | nusual ideas. busily bold deposi |
| 5751559 | 95626 | 8136 | … | NONE | SHIP | ers nag fluffily against the spe |
| 3103457 | 103115 | 8136 | … | TAKE BACK RETURN | FOB | y slyly express warthogs– unusual, e |
| 5759105 | 178135 | 8136 | … | COLLECT COD | TRUCK | es. regular pinto beans haggle. |
| … | … | … | … | … | … | … |
Dieser Ansatz ist auch nützlich für die häufige Aufgabe, zu deduplizieren, indem man den neuesten Wert innerhalb einer Gruppe findet. Ein Muster ist, den aktuellen Zustand eines Datensatzes zu finden, indem man das neueste Event in einer Event-Tabelle zurückgibt. Einfach ein
Nvon 1 verwenden!
Wir haben jetzt eine Möglichkeit, eine Aggregatfunktion zu nutzen, um die Top-N-Zeilen pro Gruppe zu berechnen! Wie viel effizienter ist das also?
Performance-Vergleiche
Wir vergleichen den QUALIFY-Ansatz mit dem max_by-Ansatz für das Top-N-nach-Gruppe-Problem.
Wir haben beide Queries besprochen, wiederholen sie aber zur Referenz unten.
`QUALIFY`-Query:
FROM lineitemSELECT *, row_number() OVER (PARTITION BY l_suppkey ORDER BY l_shipdate DESC) AS my_rankingQUALIFY my_ranking <= 3;`max_by`-Query:
FROM lineitemSELECT unnest( max_by(lineitem, l_shipdate, 3), recursive := 1 )GROUP BY l_suppkey;Während die Hauptquery läuft, starten wir außerdem einen Hintergrund-Thread, der periodisch DuckDBs Speicherverbrauch misst.
Das nutzt die eingebaute Tabellenfunktion duckdb_memory() und enthält Informationen über Speicherverbrauch sowie temporären Festplattenverbrauch.
Das kleine Python-Skript für das Benchmarking steht unter den Ergebnissen.
Die Maschine für das Benchmarking war ein M1 MacBook Pro mit 16 GB RAM.
| SF | max_memory |
Metrik | QUALIFY |
max_by |
Verbesserung |
|---|---|---|---|---|---|
| 1 | Default | Gesamtzeit | 0.58 s | 0.24 s | 2.4× |
| 5 | Default | Gesamtzeit | 6.15 s | 1.26 s | 4.9× |
| 10 | 36 GB | Gesamtzeit | 36.8 s | 25.4 s | 1.4× |
| 1 | Default | Speicherverbrauch | 1.7 GB | 0.2 GB | 8.5× |
| 5 | Default | Speicherverbrauch | 7.9 GB | 1.5 GB | 5.3× |
| 10 | 36 GB | Speicherverbrauch | 15.7 GB | 17.1 GB | 0.9× |
Wir sehen, dass der max_by-Ansatz in jeder dieser Situationen schneller ist, in manchen Fällen fast 5× schneller!
Wird der Datensatz aber größer, schwächt sich der max_by-Ansatz relativ zu QUALIFY ab.
In manchen Fällen ist der Speicherverbrauch mit max_by ebenfalls deutlich niedriger.
Der Speicherverbrauch des max_by-Ansatzes wird aber mit steigendem Scale signifikanter, weil die Zahl der unterschiedlichen l_suppkey-Werte linear mit dem Scale Factor wächst.
Dieser erhöhte Speicherverbrauch erklärt wahrscheinlich den Performance-Rückgang, da beide Algorithmen an die maximale RAM-Menge auf meiner Maschine herankamen und auf Platte auszulagern begannen.
Um den Speicherdruck zu senken, wiederholen wir den Scale-Factor-10-Benchmark (SF10) mit weniger Threads (4 Threads und 1 Thread).
Wir nutzen weiter eine max_memory-Einstellung von 36 GB.
Die vorherigen SF10-Ergebnisse mit allen 10 Threads sind zur Referenz enthalten.
| SF | Threads | Metrik | QUALIFY |
max_by |
Verbesserung |
|---|---|---|---|---|---|
| 10 | 10 | Gesamtzeit | 36.8 s | 25.4 s | 1.4× |
| 10 | 4 | Gesamtzeit | 49.0 s | 21.0 s | 2.3× |
| 10 | 1 | Gesamtzeit | 115.7 s | 12.7 s | 9.1× |
| 10 | 10 | Speicherverbrauch | 15.7 GB | 17.1 GB | 0.9× |
| 10 | 4 | Speicherverbrauch | 15.9 GB | 17.3 GB | 0.9× |
| 10 | 1 | Speicherverbrauch | 14.5 GB | 1.8 GB | 8.1× |
Der max_by-Ansatz ist so recheneffizient, dass er selbst mit 1 Thread deutlich schneller ist als der QUALIFY-Ansatz, der alle 10 Threads nutzt!
Die Thread-Zahl zu senken, hat den Speicherverbrauch ebenfalls sehr wirksam gesenkt (fast 10× Reduktion).
Wann sollten wir also welchen Ansatz nutzen?
Wie bei allen Datenbankdingen: es kommt darauf an!
Ist der Speicher knapp, kann max_by ebenfalls Vorteile bieten, besonders wenn die Thread-Zahl so abgestimmt ist, dass kein Spill auf Platte entsteht.
Gibt es aber ungefähr so viele Gruppen wie Zeilen, kommt QUALIFY in Betracht, weil wir etwas von der Speichereffizienz des max_by-Ansatzes verlieren.
Python-Benchmarking-Skript
import duckdbimport pandas as pdfrom threading import Threadfrom time import sleepfrom datetime import datetimefrom os import remove
def check_memory(stop_function, filepath, sleep_seconds, results_dict): print("Starting background thread") background_con = duckdb.connect(filepath) max_memory = 0 max_temporary_storage = 0 while True: if stop_function(): break # Profile the memory memory_profile = background_con.sql(""" FROM duckdb_memory() SELECT tag, round(memory_usage_bytes / (1000000), 0)::bigint AS memory_usage_mb, round(temporary_storage_bytes / (1000000), 0)::bigint AS temporary_storage_mb; """).df() print(memory_profile) total_memory = background_con.sql(""" FROM memory_profile select sum(memory_usage_mb) AS total_memory_usage_mb, sum(temporary_storage_mb) AS total_temporary_storage_mb """).fetchall() print('Current memory:', total_memory[0][0]) print('Current temporary_storage:', total_memory[0][1])
if total_memory[0][0] > max_memory: max_memory = total_memory[0][0] if total_memory[0][1] > max_temporary_storage: max_temporary_storage = total_memory[0][1]
print('Maximum memory:', max_memory) print('Maximum temporary_storage:', max_temporary_storage)
sleep(sleep_seconds)
results_dict["max_memory"] = max_memory results_dict["max_temporary_storage"] = max_temporary_storage background_con.close()
return
def query_and_profile(filepath, sql): con = duckdb.connect(filepath) con.sql("set max_memory='36GB'")
results_dict = {} stop_threads = False background_memory_thread = Thread(target=check_memory, args=(lambda : stop_threads, filepath, 0.1, results_dict, )) background_memory_thread.start()
print("Starting query:") start_time = datetime.now() results_df = con.sql(sql).df() results_dict["total_time_seconds"] = (datetime.now() - start_time).total_seconds() print(results_df.head(10))
stop_threads = True background_memory_thread.join() con.close()
return results_dict
filepath = './arg_max_check_duckdb_memory_v3.duckdb'
con = duckdb.connect(filepath)print("Begin initial tpch load")con.sql("""call dbgen(sf=1);""")con.close()
sql = """ FROM lineitem SELECT UNNEST( max_by(lineitem, l_shipdate, 3), recursive := 1 ) GROUP BY l_suppkey;"""
max_by_results = query_and_profile(filepath, sql)
sql = """ FROM lineitem SELECT *, row_number() OVER (PARTITION BY l_suppkey ORDER BY l_shipdate DESC) AS my_ranking QUALIFY my_ranking <= 3;"""
qualify_results = query_and_profile(filepath, sql)
print('max_by_results:', max_by_results)print('qualify_results:', qualify_results)
remove(filepath)Fazit
DuckDB bietet jetzt eine bequeme Möglichkeit, die Top-N-Werte sowohl der Aggregatfunktionen min und max zu berechnen als auch ihrer fortgeschrittenen Verwandten min_by und max_by.
Sie sind leicht zu nutzen und ermöglichen außerdem komplexere Analysen wie die Berechnung des Top N für alle Spalten oder des Top N nach Gruppe.
Im Vergleich zu einem Window-Funktions-Ansatz sind auch Performance-Vorteile möglich.
Wir würden gerne hören, auf welche kreativen Weisen Sie dieses neue Feature nutzen!
Viel Spaß beim Analysieren!