Einen interaktiven Datalog-Engine mit Rust bauen
(github.com/frankmcsherry)- Ausgehend von den im Workshop für logische Programmierung sichtbar gewordenen Grenzen bei Nutzbarkeit und Performance von Datalog-Tools begann das Experiment datatoad, einer Rust-basierten interaktiven Datalog-Shell
- datatoad zielt auf eine Struktur, in der sich während der Laufzeit Regeln hinzufügen und fortlaufend neue Fakten ableiten lassen, und senkt mit
columnar-Speicherung und LSM-Schichten die Kosten für die Deduplizierung von Faktmengen - Die Regelauswertung verarbeitet Datalog-Bodies, indem sie sie in Join-Probleme umwandelt, und trennt je nach
stable-Status zwischen vollständiger Auswertung und inkrementeller Auswertung, um bereits berechnete stable-stable-Joins zu vermeiden - In Experimenten mit dem Graspan-Datensatz sank die Alias-Analyse allein durch manuelles Umschreiben von Regeln und die Einführung von Zwischenrelationen von 736,34 Sekunden und 50,13 GB auf 119,34 Sekunden und 5,32 GB
- Weitere Arbeiten führen zu e-graph-basierter Join-Plan-Optimierung, layered trie, Byte-Repräsentationen mit fester Breite, Disk-Spill, verteilter Auswertung, Streaming-Joins und demand transform
Das Problem, das datatoad lösen will
- Beim Workshop für logische Programmierung am Memorial-Day-Wochenende traten die Unbequemlichkeiten von Datalog-Tools für Programmanalyse deutlich hervor; das war der Ausgangspunkt für den Versuch einer einfachen, brauchbaren und schnellen Datalog-Implementierung
- Das Ziel der Implementierung ist kein statischer Beispiel-Runner, sondern eine interaktive Datalog-Shell
- Sie kann große Mengen an Fakten laden
- Sie kann während der Laufzeit neue Regeln hinzufügen
- Sie spiegelt die Ergebnisse hinzugefügter Regeln fortlaufend in den bestehenden Zustand ein
- Der Code lässt sich im datatoad repository nachvollziehen
- Das bestehende datafrog liefert die Kernalgorithmen einer Datalog-Engine, erfordert aber direkte Verdrahtung durch den Nutzer; datatoad organisiert dieselben Ideen daher in einer leichter nutzbaren Form neu
- Im Nullability-Beispiel des
httpd-Dataflow-Graphen benötigte datatoad mitVec<String>-Daten und nicht kompilierten Abfragen 8,3 Sekunden und war damit rund viermal langsamer als das datafrog-Beispiel mit(u32, u32)-Daten, das etwa 2 Sekunden brauchte - Beim Reachability-Problem erzeugte es dieselbe Zahl an Ausgabe-Tupeln wie die datafrog-Implementierung, eine allgemeine Korrektheitsprüfung ist jedoch noch nicht abgeschlossen
Datalog-Modell und Shell-Struktur
- Datalog ist eine Sprache, in der man einfache logische Regeln schreibt und daraus alle durch diese Regeln erreichbaren Fakten ableitet
- Regeln bestehen aus Head und Body
- Beispiel:
tri(a, b, c) :- edge(a, b), edge(b, c), edge(a, c). triundedgesind Relationen,a,b,csind Variablen- Variablen, die im Head vorkommen, müssen auch im Body stehen
- Beispiel:
- Fakten werden als Regeln mit leerem Body behandelt
- Beispiel:
edge(1, 2) :- . - Mit mehreren Heads lassen sich mehrere Fakten gleichzeitig schreiben
- Beispiel:
- Aufgrund der Monotonie von Datalog wird die Menge wahrer Fakten durch das Hinzufügen von Regeln oder Fakten nicht kleiner, und dieselbe Eingabemenge an Regeln erreicht unabhängig von der Regelreihenfolge dasselbe Ergebnis
- Im Rust-Modell stehen drei Strukturen im Zentrum:
Rule,Atom,TermRule { head: Vec<Atom>, body: Vec<Atom> }Atom { name: String, terms: Vec<Term> }Term::Var(String)oderTerm::Lit(String)
- Für die Speicherung von Literalen wird statt
StringVec<u8>verwendet- Benötigt werden nur Literal-Gleichheit und eine beliebige Sortierreihenfolge
- Ob die Bytes
String,(u32, u32)oder eine andere Bedeutung haben, bleibt dem Nutzer überlassen
- Der Interpreter-Zustand speichert Regeln und Fakten gemeinsam
rules: Vec<Rule>facts: facts::Facts
- Wenn die Shell eine Eingabezeile als Datalog parst, ruft sie
State::extendundState::updateauf und gibt mit dem Befehl.listjeden Relationsnamen und die Zahl seiner Fakten aus
Parsing und Faktenspeicherung
- Der Parser befindet sich in
parse.rsund verwendet eine aus der Soufflé-Grammatik übernommene Form - Variablen beginnen mit
? - Tokens sind auf
.,,,(,),:-,?beschränkt; übriger Text wird als Atom- oder Term-Name behandelt - Der Tokenizer entfernt Leerraum und ersetzt
:-durch←, um es wie ein einzelnes Symbol zu scannen - Das Parsen von Regeln liest Head-Atome bis zum Turnstile und Body-Atome bis zum Punkt
- Ein Atom besteht aus Name, linker Klammer, Term-Liste und rechter Klammer
- Ein Term ist mit
?eine Variable, sonst ein Literal
- Ungültige Regeln geben
Nonezurück; derzeit wird nicht genauer erklärt, welcher Teil falsch ist - Um Negationsregeln hinzuzufügen, wäre ein
Exclamation-Token nötig, das bisher aber nicht behandelt wird
Lebenszyklus der Faktmenge
- Eine einfache Speicherung als
Vec<Vec<String>>führt zu verschachtelten Allokationen und ist daher ungünstig für das Speichermanagement - datatoad nutzt
columnar, um Rust-Typen in ein flaches Layout mit wenigen linearen Allokationen zu transformieren- String-Bytes, String-Grenzen und Faktgrenzen werden in separaten Arrays gespeichert
FactContainerkapselt eine sortierte und deduplizierte Liste von Fakten; der Wrapper-Typ signalisiert die Invarianten von Sortierung und Deduplizierung- Da der spaltenorientierte Container faktisch append-only ist und sich nicht für Änderungen in der Mitte eignet, verwendet das Hinzufügen neuer Fakten eine log-structured merge-tree (LSM)-Form
FactLSM { layers: Vec<FactContainer> }- Die Schichtgrößen werden so verwaltet, dass sie geometrisch wachsen
- Schichten mit höchstens doppelter Größe werden zusammengeführt, um Sortierung und Deduplizierung zu erhalten
FactBuilderenthält sowohl einen unsortierten, potenziell dupliziertenactive-Bereich als auch sortierte, dedupliziertelayers- Die Fakten jeder Relation durchlaufen drei Phasen
to_add: neu eingetroffene Fakten, bei denen noch nicht geprüft ist, ob sie novel sindrecent: distincte Fakten, die noch verarbeitet werden müssenstable: distincte Fakten, die bereits vollständig verarbeitet wurden
FactSet::advanceverschiebtrecentnachstableund entfernt austo_addbereits instablevorhandene Fakten, um ein neuesrecentzu bilden
Regelauswertung ist ein Join-Problem
- Der Body einer Datalog-Regel lässt sich als Equi-Join in einer relationalen Datenbank betrachten
- Das Dreiecksregel-Beispiel lautet wie folgt
tri(?a, ?b, ?c) :- edge(?a, ?b), edge(?b, ?c), edge(?a, ?c).
- Würde man alle Variablenbelegungen direkt aufzählen, wären es trotz Endlichkeit zu viele; daher wird anhand der Schlüsselspalten gemeinsamer Variablen sortiert und anschließend gemergt
- Die Implementierung reduziert den Body von rechts nach links
- Die letzten beiden Relationen werden gejoint, um eine Zwischenrelation zu erzeugen, und anschließend erneut mit der linken Relation gejoint
- Wenn der Body nur aus einem Atom besteht, wird nur in die Head-Form transformiert
JoinPlanenthält folgende Informationenbodyszum Umordnen und Filtern von Body-Atomen für den Joinjoinsmit der Key-Arität jedes Zwischen-Joins und der Ausgabeprojektionheads, die Koordinaten oder Literale für das Einfügen in Head-Atome darstellen- die Arität des letzten Head-erzeugenden Joins
- Der aktuelle Plan ist ein einfacher right-linear join plan
- Beim Erzeugen von
JoinPlanwird anhand der Position des jeweils ersten und letzten Auftretens jeder Variablen entschieden, welche Variablenwerte bis wann erhalten bleiben müssen; dabei werden die Spalten der Body-Atome in dead-, key- und value-Spalten aufgeteilt - Die zentrale Funktion ist
implement_plan(rule, plan, pos, stable, facts)- Wenn eine neue Regel hinzugefügt wird, startet sie mit
stable = trueüber alle Fakten - Bei wiederholter Anwendung bestehender Regeln berechnet sie mit
stable = falsenur neue Ableitungen
- Wenn eine neue Regel hinzugefügt wird, startet sie mit
Inkrementelle Joins und Merge Join
- Joins sind bilinear und lassen sich daher wie folgt zerlegen
(A + a) ⋈ (B + b) = A ⋈ B + A ⋈ b + a ⋈ B + a ⋈ b
- Das bereits zwischen stabilen Relationen gebildete
A ⋈ Bmuss nicht erneut berechnet werden - Wenn nur neue Ableitungen benötigt werden, werden nur drei Joins ausgeführt
A ⋈ ba ⋈ Ba ⋈ b
join_withschließt den Stable-Stable-Join je nachstable-Flag ein oder aus- Der eigentliche
joinist ein Merge Join, der zwei sortierte Eingaben sequentiell durchläuft- Wenn die Schlüssel gleich sind, wird
actionfür alle Kombinationen zu diesem Schlüssel aufgerufen - Wenn die Schlüssel verschieden sind, wird mit
gallopschnell bis zur nächsten möglichen Matching-Position weitergesprungen
- Wenn die Schlüssel gleich sind, wird
gallopist eine von EmptyHeaded übernommene Idee: Solange die monotone Bedingung erfüllt ist, wird exponentiell vorgerückt und danach per binärer Suche eingegrenzt
Experiment zur Nullability-Analyse
- Die experimentellen Daten stammen aus dem Projekt Graspan und liegen auch noch auf Google Drive
- Die Eingabe für die Dataflow-Analyse enthält zwei Relationen,
eundnn(?a, ?b): Der Wert?akann an Position?bgeschrieben werdene(?a, ?b): Der Wert an einer Position?akann an eine andere Position?bweitergegeben werden
- Die Reachability-Regel lautet wie folgt
n(?a, ?c) :- n(?a, ?b), e(?b, ?c) .
- In der
httpd-Eingabe zeigt die anfängliche.listFolgendese: 9,905,624n: 138,331
- Das direkte Ausführen der Regel dauert etwa 15 Sekunden, und
nwächst auf 9,393,283 Einträge an - Einer der Gründe für die Langsamkeit ist, dass die nach dem Join-Schlüssel umgeordnete temporäre Relation
.temp-0-0-inauf 9,393,283 Einträge anwächst - Wenn der Nutzer die Regel umschreibt, verbessert sich die Performance
m(?loc, ?val) :- n(?val, ?loc) .m(?loc, ?val) :- m(?mid, ?val), e(?mid, ?loc) .
- Mit derselben
httpd-Eingabe läuft die zweite Regel nach der Umschreibung in etwa 8.43 Sekunden
| dataflow | httpd | psql | lnx_kernel |
|---|---|---|---|
| graspan | 684s | 8640s | 42840s* |
| datatoad | 8.43s | 24.33s | 55.01s |
| datafrog | 1.30s | 4.06s | 8.03s |
- Die Graspan-Zahl für
lnx_kernelist mit*markiert; das Paper berichtet die Gesamtzeit zusammen, und wegen Kollisionen bei den Eingabe-Identifikatoren ist es schwer, das als exakt denselben Lauf zu betrachten - Für einen Vergleich mit einem in der Praxis verwendeten Tool wäre Soufflé möglicherweise ein geeigneteres Ziel
Aliasing-Analyse und manuelle Optimierung
- Die zweite Analyse ist die von Zheng und Rugina übernommene Aliasing-Analyse aus Graspan
- Es gibt zwei Eingaberelationen
A(?val, ?loc):?loc <- ?valD(?val, ?loc):?locwird in der Form*?valverwendet
- Ziel ist es, Memory Alias und Value Alias zu bestimmen
- Memory Alias: zwei lvalue-Ausdrücke können auf dieselbe Speicherposition verweisen
- Value Alias: zwei Ausdrücke können zu demselben Pointer-Wert ausgewertet werden
- In der Notation des Papers kommen
^T,^?und^*vor^T: Transposition der Relation^?: optionaler Term^*: Wiederholung ab 0-mal
- In Datalog wird
^?dargestellt, indem es auf zwei Regeln aufgeteilt wird, und^*, indem die Identity-Relation explizit eingefügt wird - Die Ausführung der anfänglichen Regeln dauert lange
- Nach Eingabe der letzten beiden Identity-Initialisierungsregeln dauert es 686.57 Sekunden bzw. 736.34 Sekunden
- Der Prozess verwendet 50.13GB
V: 361,947,256M: 92,806,768F: 2,669,647
- Durch explizite Verwendung der transponierten Relationen
-V,-M,-a,-dwerden die temporären-in-Relationen entfernt- Insgesamt 815.92 Sekunden, also etwa 13.6 Minuten
- Speicher: 31.96GB
- Der Ansatz von Zheng und Rugina ist demand-driven, und tatsächlich wird nur
Mbenötigt, daher wirdVinMinline eingebettet-Vmit 361,947,256 Einträgen wird nicht erzeugt- Der Speicher sinkt auf 18.96GB
- Das wiederkehrende Join-Fragment wird als
Fdbenannt und wiederverwendet; anschließend wirdFdirekt statt überFdberechnet, was auch das Identity-Problem reduziert - Die endgültige Form mit der eingeführten Zwischenrelation
MFdliefert folgende Ergebnisse- Laufzeit: 119.34 Sekunden
- Speicher: 5.32GB
-M: 92,806,768Fd: 1,858,986MFd: 73,474,947
- Allein durch manuelle Umschreibungen verbessern sich Speicherverbrauch und Laufzeit gegenüber dem ersten Versuch fast um das Zehnfache
- Wenn benannte Zwischenergebnisse erzeugt werden, lässt sich der gewünschte Bushy-Tree-Join-Plan bis zu einem gewissen Grad direkt aufbauen; wenn jedoch Relationen wie
V, die tatsächlich nicht benötigt werden, benannt werden, entstehen hohe Kosten durch ihre Erzeugung
Demand-driven-Abfragen und Magic Sets
- Demand-driven-Abfragen untersuchen nur die Teile, die für ein bestimmtes Zielfaktum erforderlich sind
- Als näherungsweise Lösung können Magic Sets verwendet werden
- Dabei handelt es sich um eine Transformation, die das Target-Literal in die Abfrage einbettet
- Man könnte sich überlegen, nicht bei allen
d, sondern nur bei den interessierendendzu starten, aber eine naive Anwendung kann falsch sein
- Magic Sets sind nicht zwingend die optimale Antwort, und es ist geplant, weitere einschlägige Papers zu lesen, um effizientere Ansätze zu finden
- Die zugehörigen Links sind wie folgt
Optimierung der Join-Planung und datenparalleles IR
- Der Reiz von Datalog liegt weniger in der Horn-Klausel selbst als darin, dass es das Kernproblem datenparalleler Berechnung, nämlich Data Rendezvous, in reiner Form sichtbar macht
- Die Regel
h(x, y, z) :- b1(x, y), b2(y, z) .kann als Problem verstanden werden, für jedesydie zugehörigenxundzan einem Ort zusammenzuführen - Das Grundverhalten datenparalleler Berechnung besteht darin, Records nach Schlüsseln zu gruppieren und an benutzerdefinierte Logik zu übergeben; Joins drücken dabei selektives Routing aus
- Ein einfaches IR verwendet die folgenden Opcodes
Var(String): benannte CollectionMap(Action): Filter, Permutation, ProjectionKey(usize): markiert die ersten Spalten als SchlüsselMul(usize): kombiniert mehrere Collections mit derselben Schlüssellänge
- Später werden
MapundKeyinAction.key_arityzusammengeführt Actionenthält Literal-Filter, Variablengleichheits-Filter, Projection und Key-Arity- Der einfachste Ausgangspunkt wäre, alle Body-Atome per Cross Join zu verbinden und auf jeden Head Filter und Projection zu legen; das liefert zwar die richtige Antwort, ist aber in der Performance sehr schlecht
e-graph-basierte Optimierung
- Für die Optimierung werden e-graph und Equality Saturation verwendet
- Als Referenzen dienen die egg webpage, ein früherer Artikel und
egg - Der Term-Graph wird als Map von
ENode<T>mitIddargestellt, wobei identische Knoten gemeinsam genutzt werden, um Programme kompakter darzustellen - Es wurden drei e-graph-Regeln angewendet
MulPermute: macht Eingabepermutationen vonMul(k)äquivalentMulPartition: teiltMul(k)auf verschiedene Arten auf und macht sie äquivalentMapPushdown: schiebtMapunterMul(2), um eine Join-Form mit Schlüssel zu erzeugen
- Die Beispielregel lautet wie folgt
head(?a, ?b) :- a(?x, ?a), b(?y, ?x), b(?y, ?z), a(?z, ?b) .
- Nach der Equality Saturation werden im Extraktionsschritt Kosten zugewiesen
- Die Kosten von
Mapentsprechen der Anzahl der Ausgabespalten - Die Kosten von
Mulsind die Summe aus der Anzahl der Schlüsselspalten und der Anzahl der Nicht-Schlüsselspalten der Eingaben - Die Kosten von
Varsind 0 - Bei Gleichstand werden zuerst die Anzahl der
Map, dann die Anzahl derMulminimiert
- Die Kosten von
- Im Beispiel wurde der ausgewählte Plan in Wave Two mit höchstens zwei unkorrelierten Spalten gefunden
- Map für die Eingaben
aundb - ein Join
- eine Zwischenprojektion
- ein Join mit sich selbst
- eine abschließende Projektion
- Map für die Eingaben
- Diese Plansuche dauert selbst im Release-Build etwa 40 ms, wobei der Großteil der Zeit für die Equivalence Saturation verwendet wird
Update zur Ausführung optimierter Pläne
- Im Update vom 2025-06-29 wurde die Ausführung optimierter Pläne implementiert
- Der Plan wird als
Vec<ENode<Op>>erzeugt, aber bei der tatsächlichen Ausführung wird nicht jeder Knoten unabhängig ausgeführt - Die beabsichtigte Ausführungsweise ist wie folgt
- Für jedes
Varwerden mehrere abhängigeMapin einem einzigen Scan der externen Collection angewendet - Für jedes
Mulwerden mehrere abhängigeMapin einem einzigen Join-Scan angewendet
- Für jedes
Op::Map(action)ist weniger eine direkt ausgeführte Operation als vielmehr eine Aufgabe, die für abhängige Operationen in die Queue gestellt wird- Dafür wurde
TempActioneingeführt- Literal-Filter
- Variablen-Gleichheitsfilter
- Projektion, die Spaltenreferenzen oder String-Literale enthalten kann
- In der Vorbereitungsphase des Ausführungsplans werden Body und Head getrennt und
Map-Aktionen pro Eingabeknoten gesammelt - Bei der Ausführung von
Varwird die Namensbehandlung in drei Fälle aufgeteilt- Die Erzeugung des Head verwendet den Namen der Head-Relation
- Eine Identity-Transformation verwendet den vorhandenen Eingabenamen wieder
- Eine nicht-triviale Transformation wird unter einem temporären Namen
.temp-*gespeichert
- Die Ausführung von
Mul(2)prüft die Key-Arity und die Namen der beiden Eingaben und ruftjoin_withauf, um Ergebnisse in mehrere Builder zu schreiben - Im komplexen Aliasing-Beispiel dauerte die Ausführung des optimierten Plans 114,28 Sekunden und war damit etwa 5 Sekunden schneller als zuvor mit 119 Sekunden, der Grund dafür ist jedoch unklar
- Multi-Rule-Optimierung ist noch nicht implementiert, und möglicherweise muss der Plan-Ansatz selbst noch einmal überdacht werden
Optimierung der Faktendarstellung
- 50 GB wurden auf 5 GB reduziert, dennoch wird offenbar noch etwa zehnmal mehr Speicher verwendet als eigentlich nötig
- Die größte Layer der großen Relation
-Menthält 57.289.225 Fakten und verwendet etwa 2.098.253.766 Byte- Faktengrenzen: 458.313.800 Byte
- Term-Grenzen: 916.627.600 Byte
- Tatsächliche Byte-Daten: 723.312.366 Byte
- Die erste Optimierung nutzt aus, dass die Arity konstant ist
- Wenn alle Fakten 2 Spalten haben, lassen sich Faktengrenzen allein durch Stride und Länge darstellen
- Dadurch werden Grenzdaten im Umfang von etwa 458 MB praktisch eliminiert
- Die zweite Optimierung besteht darin, die Term-Länge zu vereinheitlichen
- Wenn Zahlen als Strings mit fester Breite von 7 Stellen dargestellt werden, lassen sich auch Term-Grenzen durch Stride und Länge ausdrücken
- Dafür kann die tatsächliche Zahl der Bytes steigen
- Die dritte Optimierung besteht darin, Zahlen nicht als Text, sondern binär darzustellen
- Eine 7-stellige Zahl passt in 4 Byte eines
u32 - Sie passt auch in 3 Byte, sodass
57,289,225 × 2 × 3 = 343,735,350Byte entstehen - Damit sinkt der Bedarf von etwa 2 GB auf etwa 350 MB, also um etwa den Faktor 6,10
- Eine 7-stellige Zahl passt in 4 Byte eines
- Wenn die Wiederholung des ersten Terms komprimiert wird, sinkt der Bedarf weiter
- Es gibt 57.289.225 Fakten, aber nur 1.147.612 unterschiedliche erste Terme
- Wenn statt
(Term, Term)die Form(Term, [Term])gespeichert wird, sinkt die größte Layer auf etwa 184.491.407 Byte - Gegenüber den ursprünglichen 2 GB entspricht das einer Reduktion um etwa den Faktor 11,37
- Im Update vom 2025-07-02 wurde die erste Optimierung angewendet
- Das größte Batch hat 343.735.382 Byte, also den theoretischen Wert von 343.735.350 Byte plus 32 Byte
- Die Laufzeit sank von etwa 115 Sekunden auf etwa 95 Sekunden, also um rund 20 %
Layered-Trie-Darstellung
- Im Update vom 2025-07-20 wurde layered trie funktionsfähig
- Es wird die row-orientierte Darstellung mit der column-orientierten layered-trie-Darstellung verglichen
toad-rowtoad-col
| dataflow | httpd | psql | lnx_kernel |
|---|---|---|---|
| graspan | 684s | 8640s | 42840s* |
| toad-row | 3.88s | 11.30s | 25.67s |
| toad-col | 3.47s | 11.94s | 23.09s |
| datafrog | 1.30s | 4.06s | 8.03s |
| aliasing | httpd | psql | lnx_kernel |
|---|---|---|---|
| graspan | 8.4h | 6.0h* | 1.7h* |
| toad-row | 28.21s | 28.25s | 7.62s |
| toad-col | 19.39s | 21.96s | 9.48s |
| datafrog | UNK | UNK | UNK |
- Ein layered trie unterdrückt Wiederholungen früherer Spalten in einer sortierten Row-Darstellung
- Jede Spalte ist eine Liste von Wertelisten
- Jede Liste ist eine sortierte Liste unterschiedlicher Werte, die einem Prefix der vorherigen Spalten entspricht
- Die Anzahl der Listen einer Spalte entspricht der Gesamtzahl der Elemente der vorherigen Spalte
- Es lässt sich wie ein Baum betrachten, die tatsächliche Implementierung bleibt jedoch spaltenbasiert
- Der Vorteil besteht darin, dass Suche, Join, Differenzmenge und Merge auf Prefix-Ebene ausgeführt werden können
- Wenn es fast keine unterschiedlichen Werte gibt und es günstiger ist, ganze Zeilen auf einmal zu betrachten, kann ein row-orientierter Ansatz vorteilhafter sein
- Als gemeinsame Abstraktion wurde das Trait
FactContainereingeführtformlenapplyjoinexceptmerge
applyverfolgt statt Rekursion mit einem expliziten Stack den Bereich jeder Layer, erzeugt Zeilen und übergibt sie an die Aktionalignist ein gemeinsamer Helfer, um die Prefixe zweier layered tries aufeinander abzustimmenOrdering::Less: ein Bereich, der nur in self vorkommtOrdering::Greater: ein Bereich, der nur in other vorkommtOrdering::Equal: ein Prefix der Länge der Arity, das auf beiden Seiten gemeinsam ist
join,exceptundmergesind alle aufalignaufgebautjoinentfaltet bei gemeinsamem Prefix die verbleibenden Erweiterungen und führt einen Cross Join ausexceptpfropft Bereiche, die nur in self vorhanden sind, mitTrieBuilderanmergepfropft self-only-, other-only- und equal-Bereiche jeweils passend genau einmal an
Optimierung mit fester Breite und Performance
- Wenn ein Upgrade auf feste Breite
[u8; 4]möglich ist, verbessert sich die Vergleichsleistung deutlich - Da layered tries Optimierungen mit fester Breite pro Spalte anwenden können, könnten sie langfristig gegenüber row-Darstellungen im Vorteil sein
- Auf
exceptundmergeließen sichupgradeunddowngradeleicht anwenden, beijoinwar die Anwendung wegen Rust-Typproblemen schwieriger
| dataflow | httpd | psql | lnx_kernel |
|---|---|---|---|
| graspan | 684s | 8640s | 42840s* |
| toad-row | 3.88s | 11.30s | 25.67s |
| ^-- +opt | 3.11s | 9.49s | 19.83s |
| toad-col | 3.47s | 11.94s | 23.09s |
| ^-- +opt | 2.55s | 9.13s | 15.95s |
| datafrog | 1.30s | 4.06s | 8.03s |
| aliasing | httpd | psql | lnx_kernel |
|---|---|---|---|
| graspan | 8.4h | 6.0h* | 1.7h* |
| toad-row | 28.21s | 28.25s | 7.62s |
| ^-- +opt | 23.31s | 23.08s | 6.73s |
| toad-col | 19.39s | 21.96s | 9.48s |
| ^-- +opt | 14.26s | 16.45s | 8.33s |
| datafrog | UNK | UNK | UNK |
- Derzeit werden etwa zwei Drittel der Laufzeit unter
joinverbraucht - Bei der Join-Optimierung dürfte noch mindestens ungefähr ein zusätzlicher Faktor 2 möglich sein
- Ein einfacher Versuch, nur die innere Schleife neu anzuordnen, brachte keine messbare Verbesserung
Auf die Ebene des kompilierten Codes spezialisieren
- Wenn Terme gleicher Länge und Fakten gleicher Arität erkannt werden, kann
Vec<u8>wieVec<[[u8; B]; T]>behandelt werden - In dieser Form kennt Rust die Datenstruktur besser, wodurch Kosten für Bounds- und Längenprüfungen sinken und insbesondere Vergleiche sehr billig werden
- Vergleiche werden an mehreren Stellen in datatoad verwendet
- Sortieren und Deduplicating von Fakt-Batches
- Mergen von Batches
- Mergen von Join-Schlüsseln
- Filtern neuer Fakten durch Vergleich mit bestehenden Fakten
- Die Benchmark-Ergebnisse sehen wie folgt aus
| dataflow | httpd | psql | lnx_kernel |
|---|---|---|---|
| graspan | 684s | 8640s | 42840s* |
| datatoad | 7.44s | 17.26s | 42.25s |
| datafrog | 1.30s | 4.06s | 8.03s |
| aliasing | httpd | psql | lnx_kernel |
|---|---|---|---|
| graspan | 8.4h | 6.0h* | 1.7h* |
| datatoad | 101.24s | 96.36s | 20.20s |
| datafrog | UNK | UNK | UNK |
- Die Sortieroptimierung wurde experimentell umgesetzt, indem
Vec<u8>perunsafetransmute inVec<[u8; 8]>umgewandelt und dann sortiert sowie dedupliziert wurde
| dataflow | httpd | psql | lnx_kernel |
|---|---|---|---|
| dt-orig | 7.44s | 17.26s | 42.25s |
| dt-sort | 4.99s | 13.55s | 32.15s |
| datafrog | 1.30s | 4.06s | 8.03s |
| aliasing | httpd | psql | lnx_kernel |
|---|---|---|---|
| dt-orig | 101.24s | 96.36s | 20.20s |
| dt-sort | 52.99s | 53.19s | 11.20s |
| datafrog | UNK | UNK | UNK |
- Die Merge-Optimierung wurde als einfache Methode implementiert, bei der beide Eingaben zusammengehängt und anschließend sortiert sowie dedupliziert werden
| dataflow | httpd | psql | lnx_kernel |
|---|---|---|---|
| dt-orig | 7.44s | 17.26s | 42.25s |
| dt-sort | 4.99s | 13.55s | 32.15s |
| dt-both | 3.71s | 11.23s | 23.58s |
| datafrog | 1.30s | 4.06s | 8.03s |
| aliasing | httpd | psql | lnx_kernel |
|---|---|---|---|
| dt-orig | 101.24s | 96.36s | 20.20s |
| dt-sort | 52.99s | 53.19s | 11.20s |
| dt-both | 31.32s | 30.08s | 8.56s |
| datafrog | UNK | UNK | UNK |
- Die kompilierte Performance von datafrog ist noch nicht erreicht
- Verbleibende Vergleichskosten gibt es auch bei join und antijoin; beim Wechsel zu trie-basierten Strukturen sollen dieselben Optimierungsmöglichkeiten erneut betrachtet werden
- Es wird nach einem Weg gesucht,
unsafezu entfernen, und zusätzlich wird das Fazit gezogen, dassunsafe-Code vermieden werden sollte
Noch offene Arbeit
-
Spill auf Festplatte
- Der
columnar-Speicher besteht aus wenigen großen Allokationen - Beim Erzeugen kann statt in den Speicher in eine Datei geschrieben und diese anschließend per Memory-Mapping wiederverwendet werden
- Der
-
Verteilte Auswertung
- Join, Deduplicating und Distinctness-Checks basieren auf Schlüsselgleichheit
- Schlüssel und die zugehörigen Daten lassen sich auf mehrere Worker verteilen
- Mit
timely_communicationlässt sich das auf mehrere Prozesse erweitern
-
Streaming-Regelauswertung
- Der aktuelle Join verwendet binäre Joins und materialisierte Ausgaben
- Wenn die nötigen Indizes vorhanden sind, lässt sich ein Plan erstellen, der keinen internen Zustand materialisiert
- Worst-case-optimal Join soll hier ebenfalls behandelt werden
-
Spezialisierung auf benutzerdefinierte Repräsentationen
- Es gibt die Idee, transitive closure zu erkennen und auf strongly connected component decomposition zu spezialisieren
- Für equivalence relation kann eine Union-Find-Datenstruktur verwendet werden
bddbddbund factorized databases behandeln ebenfalls verwandte Themen
-
Suche nach relevanten Fakten
- demand transform muss verstanden und angewendet werden
- Für interaktive Datalog-Erkundung wird das als notwendige Transformation angesehen
1 Kommentare
Hacker-News-Kommentare
Interessant, dass dieser Beitrag auf Platz 1 gelandet ist
Ich baue gerade mit Differential Datalog und Rust ein Echtzeit-Strategiespiel und lasse die Spiellogik von DDL verwalten
Größtenteils ist es eher ein Vorwand, um eine neue Idee kennenzulernen und mich in endloses Yak-Shaving zu stürzen
https://github.com/vmware-archive/differential-datalog
Zur Einordnung: Das ddlog-Team hat inzwischen Feldera gegründet, und es könnte sich lohnen, DBSP direkt aus Rust heraus zu verwenden
https://github.com/feldera/feldera
https://github.com/salsa-rs/salsa
DDLog wird nicht mehr aktiv gepflegt, was es noch interessanter macht
„Ich, der berüchtigte Schurke, war eingeladen worden und war halb überzeugt, dass ich unterwegs war, die längst fällige Vergeltung zu empfangen.“ — die beste erste Zeile eines technischen Blogposts, die ich dieses Jahr gelesen habe
Auch die Einschübe des Erzählers waren großartig; selten liest sich ein Beitrag so technisch tiefgehend und zugleich so unterhaltsam
Die Reise zur Optimierung von Alias-Abfragen fühlte sich an wie ein Krimi, und man stöhnt als Leser mit über die 50 GB Speicherverbrauch und jubelt dann, als es auf 5 GB heruntergeht
Sowohl Code als auch Text sind hervorragend
Ich habe ein wenig daran gearbeitet, mangle datalog nach Rust zu portieren
Es liegt unter https://github.com/google/mangle/tree/main/rust und befindet sich im selben Repository wie die Go-Implementierung
Es hat keine hohe Priorität, und dazu kommt noch Second-System-Syndrome, daher geht es nur langsam voran
Mangle Rust zielt darauf ab, Daten beliebiger Größe zu verarbeiten, indem Fakten per Memory Mapping von der Platte gelesen und geschrieben werden; die Go-Implementierung arbeitet dagegen im Speicher
Dieser Beitrag ist gut, weil er Datalog parst und auch LSM-Trees behandelt, und er ist deutlich leichter nachzuvollziehen als Materialien zu datafrog
In Rust gibt es viele Datalog-Implementierungen mit prozeduralen Makros, etwa ascent und crepe, aber sie haben den Nachteil, dass es schwierig ist, Abfragen zur Laufzeit entgegenzunehmen
Für statische Analysen mit festen Abfragen und Programmen kann der Ansatz mit prozeduralen Makros allerdings besser sein
Auch wenn die aktuelle Datalog-Renaissance etwas nachzulassen scheint, ist es schön zu sehen, dass der harte Kern der Enthusiasten weiter durchhält
Die jüngste Datalog-2.0-Konferenz war deutlich kleiner als früher, und auch auf der zweiten HYTRADBOI-Konferenz spielte Datalog eine geringere Rolle
Bei der ersten HYTRADBOI hatte ein Viertel der Einreichungen mit Datalog zu tun
Es ist ermutigend, dass andere ihre aktuellen Datalog-Projekte teilen
Ich bereite derzeit eine große Softwaremigration vor und baue dafür eine Datenqualitäts-Pipeline für eine Legacy-SQL-Datenbank
Wenn man Abfragen gut strukturiert, sind sie sehr gut lesbar; zum Identifizieren und Auffinden von Datenqualitätsproblemen halte ich Datalog für deutlich nützlicher als SQL
Datalog 2.0 ist ein Satelliten-Workshop der in Europa eher weniger bekannten Konferenz LPNMR, und diese Konferenz fand zufällig in Dallas statt
Als ich selbst dort war, wirkte die Veranstaltung ebenfalls etwas leer; ich hatte auch ein Paper im Workshop, sah aber nicht viele Leute aus dem Feld
Auffällig waren als Ausnahme die Leute aus Europa, die den Nemo-Solver vorstellten
Dass dieses Jahr so wenige teilnahmen, zeigt meiner Ansicht nach eher, dass es ein Satelliten-Workshop einer ohnehin nicht besonders renommierten Konferenz war und dass die Hauptveranstaltung zudem ICLP war, als dass es mangelndes Interesse an Datalog-Implementierungen belegt
Natürlich will ich damit nicht der größeren These widersprechen, dass bei Implementierungen reiner Datalog-Engines kaum noch Neues übrig ist
Der Forschungsraum ist längst weitergezogen, hin zu exotischeren Problemen wie Streaming (HydroFlow), Choice (Dusa) und Dingen, die einem allgemeinen Chase nahekommen (die Chase-Engine von Egglog)
Dass Vanilla-Datalog langweilig ist, dürfte weitgehend unstrittig sein; monotone Vorwärts-Sättigung und Horn-Klauseln sind aber eine reichhaltige Baseline mit gut verstandener Performance-Engineering-Landschaft, auf der sich interessantere Theorien wie Semiringe oder Z-Sets gut aufbauen lassen
Wenn dir die Zustandsmaschine und der Parsing-Teil gefallen haben, empfehle ich auch Rob Pikes alten Vortrag Lexical Scanning in Go
https://www.youtube.com/watch?v=HxaD_trXwRE
Er ist in Go, aber das meiste lässt sich leicht auf andere Sprachen übertragen
Schön, dass moderne Sprachen wie Rust, Zig und Go Unicode/Rune/Graphem nativ unterstützen
Im Vergleich zu Java, .NET, C++ oder Skriptsprachen verschwinden dadurch viele Probleme einfach
Ich mag die Datalog-Arbeit des Autors insgesamt, aber ich wünschte, in Einführungen würden keine binären Joins gelehrt
Sobald man die Idealfälle verlässt, wird das Innenleben schnell unübersichtlich, und Methoden für allgemeine Joins ließen sich in meinem Kopf viel leichter verallgemeinern
https://en.wikipedia.org/wiki/Worst-case_optimal_join_algorithm
https://github.com/frankmcsherry/blog/blob/master/posts/2025-05-29.md
Vor langer Zeit bin ich im Studium ein wenig mit Prolog in Berührung gekommen, und ich weiß grob, wofür es verwendet wird und wobei es nützlich ist, aber wirklich tief verstanden habe ich es nicht.
Seitdem höre ich immer wieder, dass Datalog großartig sei, aber ich bekomme nicht richtig zu fassen, was Datalog gegenüber Prolog verbessert.
Ich habe gerade die Datalog-Seite auf Wikipedia überflogen; danach scheint Prolog vergleichsweise schlechte Performance zu haben, während Datalog Ausdrucksstärke und Funktionen reduziert und dafür die Performance deutlich verbessert, sodass größere Datensätze und stärker parallelisierte Verarbeitung möglich werden.
Offenbar gehört auch dazu, dass die Turing-Vollständigkeit wegfällt; ich frage mich, ob das der Kern ist oder ob ich damit völlig danebenliege.
cut verhindert nicht nur unnötige Berechnungen, sondern kann auch das Ergebnis beeinflussen.
Datalog hingegen ist im Großen und Ganzen eher wie eine relationale Datenbank mit anderer Syntax.
Ein trivialer, aber riesiger Suchraum in Prolog kann in Datalog so viel Speicher verbrauchen, dass er sich gar nicht ausdrücken lässt.
Datalog ist ein Pendlerauto mit CVT, Prolog eher ein F1-Wagen.
Es ist weniger eine Verbesserung als vielmehr eine teilweise Kastration von Prolog, damit sich die Leute nicht selbst den Fuß wegschießen, und außerdem ist es viel einfacher, es in andere Anwendungen zu implementieren und einzubetten.
Wer mit Prolog vertraut ist, wird Datalog meist als einschränkend empfinden.
Es gibt kein
call/3, keine term/goal expansion, und im Grunde ist Datalog darauf ausgelegt, den kleinsten gemeinsamen Funktionsumfang von Prolog herauszuziehen und für interaktive Datenbanksuchen zu verwenden.Schnellen Datalog-Code zu schreiben ist leicht, aber die Obergrenze liegt auch deutlich niedriger.
Prolog kann ebenfalls so geschrieben werden, dass es Nebenläufigkeit zulässt, aber das ist eine Aufgabe für Fortgeschrittene, bei der man die Implementierung verstehen muss.
Guarded Horn Clauses und davon abgeleitete Sprachen wurden entwickelt, um solche Aspekte zu formalisieren, aber die Entwicklungen in Japan nach Prolog sind sehr schwer zugänglich.
Die Performance von Prolog hängt stark vom Programmierer, der verwendeten Implementierung und dem Einsatzort ab.
Wie Lisp kann auch Prolog zur Compile-Zeit aus einer DSL nativen Maschinencode erzeugen.
Wenn man versteht, wie die zugrunde liegende Implementierung arbeitet, und den Code entsprechend schreibt, ist es ausreichend schnell.
Dafür muss man allerdings über Jahre hinweg Prolog-Code mit ein und derselben Implementierung schreiben.
Es gibt auch viel Forschung zu Optimierungen von Prolog-Compilern und proprietäre Implementierungen.
http://logicprogramming.stanford.edu/readings/ullman.pdf
https://www.ueda.info.waseda.ac.jp/AITEC_ICOT_ARCHIVES/ICOT/Museum/IFS/abst/078.html
https://www.sciencedirect.com/science/article/pii/S0743106696889813
https://link.springer.com/content/pdf/10.1007/3-540-18024-9_26.pdf
https://sicstus.sics.se/
Wenn man Datalog und Rust verwenden möchte: cozodb ist in Rust geschrieben und bietet auch eine Datalog-Abfragesyntax.
Als ich es etwa im November 2024 angeschaut habe, habe ich ein paar naheliegende Verbesserungsmöglichkeiten im SQLite-Storage-Backend gefunden.
https://github.com/cozodb/cozo/issues/285
Ich habe es auch für statische Programmanalyse verwendet; intern nutzt es Sortierbäume und Typtechniken.
Die Dokumentation ist ausreichend, sodass man sie mit den schrittweisen Erklärungen im Blog vergleichen kann, und besonders die Arbeit an der Query-Optimierung ist interessant.
Allerdings sind die Kosten für Datenserialisierung hoch, wenn man nicht in Rust im Speicher arbeitet, und das Projekt ist bestenfalls ruhig.
Früher sagten Clojure-Fans, Datalog sei besser als SQL, und es sei schade, dass relationale Datenbanken alle SQL verwenden.
Ich habe nie gründlich genug nachgeforscht, warum sie das dachten.
http://canonical.org/~kragen/binary-relations begann als einfache nicht-rekursive Abfrage, aber die SQL-Übersetzung ist schon kriminell, und eine sauber getrennte SQL-Lösung wäre ein Kapitalverbrechen.
In jüngerer Zeit wurden ANSI SQL rekursive Funktionen hinzugefügt, sodass es nicht mehr völlig unmöglich ist, aber es gibt drei große Nachteile.
Erstens hat man SQL versehentlich Turing-vollständig gemacht; Datalog-Abfragen hingegen terminieren garantiert.
Zweitens ist es weiterhin furchtbar schwerfällig zu benutzen.
Drittens wird es wegen des ersten Punkts oft nicht vollständig implementiert, sodass man sich nur schwer darauf verlassen kann.
Wenn man Datalog online in einer freundlichen Notebook-Umgebung ausprobieren möchte, empfehle ich Percival.
https://percival.ink/
Für Datalog-Implementierungen insgesamt gibt es keinen Standard, der „ANSI SQL“ entspräche, aber wenn man die Kernideen verstanden hat, sind andere Datalog-Varianten auch nicht allzu schwierig.
Ich habe auch einen Percival-Fork begonnen, der Datalog nach SQLite kompiliert; wer sehen möchte, wie beide dasselbe ausdrücken, kann ihn sich ansehen.
https://percival.jake.tl/
Aggregationen und fortgeschrittenere Joins sind noch unvollständig, aber die Grundform funktioniert gut.
Logica ist ein deutlich ernsthafterer und ausgereifterer Datalog→SQL-Compiler von Google-Forschern; er kompiliert nach BigTable, DuckDB und in einige SQL-Dialekte.
https://logica.dev/
Der Bereich, in dem Datalog um mehr als eine Größenordnung einfacher wird, sind rekursive Abfragen/Regeln.
In SQL ist das auch möglich, fühlt sich aber eher an, als würde man Play-Doh durch einen Strohhalm trinken.
Bei Franks Materialize.com gibt es eine SQL-Form „WITH MUTUALLY RECURSIVE“, die viel besser ist als der alte rekursive ANSI-SQL-Ansatz und bei Notion für Page-Load-Abfragen und Datensynchronisierung evaluiert wird.
https://materialize.com/blog/recursion-in-materialize/
Feldera hat für rekursive Views ebenfalls eine ähnliche Form.
https://www.feldera.com/blog/recursive-sql-queries-in-feldera
Mir gefällt an Feldera, dass man jede „Regel“ oder Unter-View als eigene Anweisung formulieren kann, statt alles in eine riesige Anweisung packen zu müssen.
Der wichtigste Nachteil, den ich beim Testen gesehen habe, ist, dass Felderas SQL-Dialekt ziemlich viele Einschränkungen von Apache Calcite geerbt hat; der Materialize-SQL-Dialekt dagegen bemüht sich sehr stark um PostgreSQL-Kompatibilität.
Ein neuer McSharry-Artikel, hervorragend.
Als ich zuletzt nachgesehen habe, schien sich VMWare von differential datalog entfernt zu haben.
https://www.feldera.com/
Es sieht so aus, als seien sie von differential Datalog zu differential SQL gewechselt, vermutlich weil ihnen klar wurde, dass Datalog wirklich schwer zu verkaufen ist.