Entwicklung eines neuen JIT-Compilers für PostgreSQL
(pinaraf.info)- Die experimentelle JIT-Engine pg-copyjit zielt darauf ab, auch für kurze PostgreSQL-Abfragen „schnell genug“ erzeugten Code mit geringeren Generierungskosten als LLVM zu liefern
- Die Kostenschätzung von PostgreSQL entspricht nicht direkt der tatsächlichen Laufzeit, weshalb der aufwendige LLVM-JIT bei kurzen Abfragen sogar nachteilig sein kann
- Der Copy-and-Patch-Ansatz kompiliert Stencils vorab in C und kopiert bzw. patcht zur Laufzeit die benötigten Fragmente, damit sie wie neue Funktionen ausgeführt werden können
- pg-copyjit wird über die JIT-Provider-Schnittstelle von PostgreSQL eingebunden und fällt bei nicht implementierten Opcodes automatisch auf den PostgreSQL-Interpreter zurück
- Aktuell befindet sich das Projekt im Proof-of-Concept-Stadium für PostgreSQL 16 und AMD64; die Codegenerierung liegt bei einigen hundert Mikrosekunden, aber Build, Dokumentation und Support sind noch nicht bereit
Worauf pg-copyjit abzielt
- pg-copyjit ist eine experimentelle JIT-Engine, die den PostgreSQL-Server schneller machen soll
- Der aktuelle Code ist eher auf einem Niveau, das für erfahrene Hacker zum Experimentieren geeignet ist, als für Produktionsserver
- Erwünschtes Feedback sind interessante Experimentergebnisse, Performance-Verbesserungen und Implementierungsideen; ein Stadium, in dem man Ausfallzeiten bei geschäftskritischen Anwendungen in Kauf nimmt, ist es noch nicht
Warum LLVM JIT in PostgreSQL belastend sein kann
- PostgreSQL hat bereits einen von Andres Freund eingeführten LLVM-basierten JIT-Compiler
- LLVM kann effizienten Code erzeugen, aber in der Art, wie es in PostgreSQL genutzt wird, sind JIT-Kompilierung und Optimierung teuer
- Ohne Optimierer kann es schlechter sein als gar nicht zu kompilieren, und mit Optimierer können die Kosten noch weiter steigen
- Zur Entscheidung, ob JIT angewendet wird, wird die allgemeine query cost estimation verwendet
- Die Kosten in PostgreSQL sind ein Vergleichswert zwischen Abfragen und stehen nicht für die tatsächliche Ausführungszeit
- Eine Abfrage mit Kosten 100 kann 1 Sekunde dauern, während eine Abfrage mit Kosten 1000 in 100 ms fertig sein kann
- Wenn eine Abfrage zwar 10 ms schneller wird, die Optimierung aber 50 ms kostet, ist die Gesamtausführungszeit schlechter
- Eine Möglichkeit, LLVM JIT nützlicher zu machen, wäre, kompilierte Abfragen zu cachen und wiederzuverwenden, aber die Umsetzung ist kein kleines Vorhaben
Struktur des Copy-and-Patch-Ansatzes
- Copy-and-Patch ist ein 2021 in einer wissenschaftlichen Arbeit vorgestellter Aufbau für JIT-Compiler und wird auch in der JIT-Engine von Python 3.13 verwendet
- Die Kerneinheit ist ein in C geschriebener Stencil
- Ein Stencil ist eine Funktion mit Lücken
- Er wird vorab mit
clangkompiliert - Unterstützung für
gccist vorerst zurückgestellt
- Zur Kompilierzeit werden die benötigten Stencils aneinandergehängt, die Lücken gefüllt und dann in die neu erzeugte „kompilierte“ Funktion gesprungen
- Der Grundablauf ist einfach
- Ein Stencil wird in einen neuen Speicherbereich kopiert
- Die benötigten Werte werden gepatcht
- Der resultierende Code wird ausgeführt
- Es gibt Raum für zusätzliche Optimierungen
- Werte, die sich schon zur Kompilierzeit berechnen lassen, werden vorab berechnet
- Schleifen werden zum Unrolling in mehrere Stencils aufgeteilt
- Mehrere Stencils lassen sich zu einem Meta-Stencil kombinieren, um sie gemeinsam zu optimieren
Wie es an PostgreSQL angebunden wird
- Das JIT-System von PostgreSQL besitzt eine erweiterbare Provider-Struktur
- Die
.sostellt genau eine Funktion_PG_jit_provider_initbereit, die drei Callbacks initialisiertcompile_exprrelease_contextreset_after_error
- Der wichtigste Callback ist
compile_expr- Die Eingabe ist ein Ausdruckszeiger
ExprState*, der aus Opcodes besteht - Die Opcodes werden auf beliebige gewünschte Weise kompiliert
- Der erzeugte Code wird als ausführbar markiert
evalfuncwird vom PostgreSQL-Interpreter auf den erzeugten Code umgestellt
- Die Eingabe ist ein Ausdruckszeiger
- Wenn ein nicht implementierter Opcode auftaucht, kann automatisch auf den PostgreSQL-Interpreter zurückgefallen werden
Der Kompilierablauf von pg-copyjit
- Der Copy-and-Patch-Algorithmus von pg-copyjit ist derzeit noch einfach und enthält nur einige kleine Optimierungen
- Für jeden Opcode prüft der Compiler eine Stencil-Sammlung
- Wenn es einen zum Opcode passenden Stencil gibt, wird er an den gerade erzeugten Code angehängt
- Wenn es keinen Stencil gibt, wird die Kompilierung abgebrochen und der PostgreSQL-Interpreter übernimmt die Ausführung
- Nachdem der Stencil angehängt wurde, werden alle Lücken mit den benötigten Werten gepatcht
- Der Stencil für den Opcode
CONSTdeklariertopals externesExprEvalStep, und in der kompilierten.o-Datei bleibt eine Lücke für die Adresse vonop - Die Stencil-Sammlung bewahrt diese Relocation-Informationen, und der JIT-Compiler setzt die Adresse der aktuellen Opcode-Struktur ein, um ausführbaren Code zu erzeugen
- Beim Build werden die Stencils zunächst zu einer einzelnen
.o-Datei gebaut; daraus werden anschließend Assemblercode und Relocations extrahiert und in in C verwendbare Strukturen umgewandelt
Implementierungsstand und Performance
- Anfangs wurde der Assemblercode manuell extrahiert, um die drei für
SELECT 42;benötigten Opcodes zum Laufen zu bringen - Danach wurde ein DirtyPython-Skript geschrieben, das die Extraktion des Assemblercodes automatisiert, und innerhalb weniger Stunden kamen folgende Funktionen hinzu
- Funktionsaufrufe
- Abfragen auf einer einzelnen Tabelle
- Komplexere Datentypen
- Einige Optimierungen
- Der aktuell bestätigte Stand ist wie folgt
- Funktioniert mit PostgreSQL 16
- Es wird erwartet, dass es auch mit früheren Releases funktioniert, bestätigt ist aber PostgreSQL 16
- Unterstützte Architektur ist derzeit nur AMD64
- Unterstützung für ARM64 soll hinzugefügt werden
- Ziele wie POWER64 und S390x sind ebenfalls von Interesse, könnten aber Compiler-Patches und Zugriff auf die jeweiligen Maschinen erfordern
- Die Performance-Zahlen stammen noch aus einem Zustand mit kaum Optimierung
- Die Codegenerierung ist in einigen hundert Mikrosekunden abgeschlossen
- Das ist ein Niveau, das auch bei kurzen Abfragen nutzbar ist
- Bei
SELECT 42;braucht No JIT 0,3 ms, copyjit 0,6 ms, LLVM ohne Optimierung 1,6 ms und LLVM mit Optimierung 6,6 ms
- LLVM kann sehr schnellen Code erzeugen, aber das Ziel von pg-copyjit ist es, schnell hinreichend schnellen Code zu erzeugen; daher ist ein direkter Vergleich der beiden Werkzeuge schwierig
- Auf einer einfachen nicht indizierten Tabelle mit 90k Zeilen wurden zwei Abfragen benchmarked; wenn eine
where-Klausel mit CPU-Arbeit enthalten ist, verbessert sich die Performance gegenüber dem Interpreter - Die Benchmarks wurden auf einem Laptop durchgeführt und sind daher nur begrenzt belastbar; künftig sind geeignetere Benchmarks auf einem Desktop geplant
- Auch mit noch wenigen implementierten Opcodes kann jede Abfrage ausgeführt werden
- Die JIT-Engine gibt für nicht implementierte Teile Meldungen aus
- Die eigentliche Ausführung übernimmt der Interpreter
Code-Veröffentlichung und verbleibende Arbeit
- Der Code ist auf GitHub unter pg-copyjit veröffentlicht
- Der Fokus liegt derzeit eher auf dem Code selbst als auf bereinigter Git-History oder Dokumentation
- Zum Bauen muss die Datei
build-stencils.shderzeit zuerst manuell ausgeführt werden - Im aktuellen Zustand kann noch kein Support angeboten werden, daher gibt es auch noch keine Dokumentation
- Die verbleibenden Aufgaben sind vergleichsweise klar
- Mehr Opcodes implementieren
- Optimierungen ausloten
- Den Build-Prozess vereinfachen
- Alles in einen paketierbaren Zustand bringen
- Das Build-Skript ist derzeit auf Debian und PostgreSQL 16 zugeschnitten
Erwartete Einsatzgebiete und Architektur-Erweiterung
- Das Ziel ist, einen Stand zu erreichen, an dem sich das Projekt sicher paketieren und auf dem eigenen Produktionsserver ausrollen lässt
- Vorgesehen ist auch ein serverweiser Einsatz unterschiedlicher JITs
- Auf GIS-Servern, wo sich die Optimierungskosten lohnen, könnte LLVM JIT genutzt werden
- Für Datenbanken von Webanwendungen, bei denen kurze Abfragezeiten wichtig sind, könnte pg-copyjit verwendet werden
- Auch die Portierung auf andere Architekturen ist ein ernsthaftes Ziel
- Mit Blick auf die Zeit vieler unterschiedlicher Architekturen wie Alpha, Itanium, Sparc und M68k ist die Haltung, dass man sich nicht an einer Monokultur rund um nur eine Architektur beteiligen möchte
1 Kommentare
Hacker-News-Kommentare
Der JIT-Compiler muss Speicheradressen direkt einbetten, wodurch der erzeugte Code an die jeweilige Query und den jeweiligen Prozess gebunden ist
Ausführungspläne haben dasselbe Problem, weil sie keine Speicherstrukturen sind, die für die gemeinsame Nutzung zwischen verschiedenen Prozessen ausgelegt wurden
DBs wie MSSQL haben diese Einschränkung nicht, weil sie als Single-Process-Architektur mit Threads arbeiten, und können deshalb auch ohne externen Pooler mehr gleichzeitige Verbindungen verarbeiten
MSSQL kann Ausführungspläne außerdem in eine nicht an den Prozess gebundene Darstellung serialisieren und in der DB speichern, was sich für Funktionen wie das Fixieren von Ausführungsplänen nutzen lässt
Herkömmliche Kompilierung wirkt auch wie eine Art „copy-and-patch“ mit einer anderen Zwischensprache statt C
Zum Beispiel, welche Passes und welche Backend-Optimierungen verwendet werden
Unserer Erfahrung nach [1] kann selbst LLVM deutlich schneller werden, wenn man die Optimierungen deaktiviert und es auf Kompilierzeit mit einer
-O0-Backend-Pipeline abstimmt, ist aber immer noch 10- bis 20-mal langsamer als andere AnsätzeAußerdem ist in unserer Erfahrung der mit copy-and-patch erzeugte Code bei der Ausführung ziemlich langsam und schwer zu optimieren. Wir haben einiges ausprobiert [2; Sec. 5], aber der Abstand ist immer noch groß. Für die Datenbank-Evaluation siehe Fig. 3
Mich würde interessieren, ob es Zahlen zum Laufzeitverlust gegenüber LLVM gibt und ob geplant ist, einen mehrstufigen JIT zu implementieren, der dynamisch von schnell kompiliertem Code auf LLVM-optimierten Code umschaltet
[1]: https://home.in.tum.de/~engelke/pubs/2403-cgo.pdf
[2]: https://home.in.tum.de/~engelke/pubs/2403-cc.pdf
Als ich um 2010 Programmierung gelernt habe, besonders Interpreter, hielt ich es für allgemein bekannt, dass man vom Compiler erzeugte Stücke ausführbaren Codes mit
memcpykopieren kann, wenn man vorsichtig vorgehtDamals war eine große Falle, dass sich das NX-Bit gerade erst verbreitete. Selbst unter Linux gingen die meisten immer noch selbstverständlich von 32-Bit-Distributionen aus, und manche waren überrascht, dass ihre CPU 64 Bit unterstützt
Später benutzte ich sogar ein Netbook, das überhaupt keinen 64-Bit-Code unterstützte
Leider habe ich zu viel Zeit in den Rest des Codes gesteckt, um das tief genug zu verfolgen und etwas wirklich Brauchbares daraus zu machen
Leider ist der Call for Proposals schon vorbei, aber es gibt noch einen „unconference“-Teil. Die Themen werden allerdings erst vor Ort festgelegt, also gibt es keine Garantie
Wenn es einen Cache gäbe, könnten häufig ausgeführte Queries zwischengespeichert und aggressiver optimiert werden, sodass die Kompilierkosten kompensiert würden und beide Ansätze gut zusammenspielen könnten
Natürlich würde das für sich genommen eine ganz neue Ebene an Komplexität und Problemen hinzufügen
Siehe „notes“ unter https://www.postgresql.org/docs/current/sql-prepare.html
Stark vereinfacht leaken überall in der Execution Engine Pointer auf Teile der Query
Um das zu beseitigen, wäre eine ziemlich umfassende Überarbeitung der Execution Engine, des Planners und wohl noch weiterer Teile nötig
Selbst innerhalb einer einzelnen Session haben zwei kompilierte Queries deshalb unterschiedlichen kompilierten Code. Sowohl LLVM als auch mein copyjit müssen die Adressen mehrerer Strukturen in den Assemblercode einbetten
Normalerweise musste man dann selbst eingreifen und Hints hinzufügen oder den Ausführungsplan erzwingen
Selbst eine einfache Query wie
SELECT * FROM t WHERE x = TRUE;konnte je nach Verteilung derx-Werte in der Tabelle zum Albtraum werdenMit Postgres hatte ich solche Probleme fast nie, auch wenn ich zugeben muss, dass ich Postgres nie zusammen mit Prepared Statements verwendet habe
Ich habe zwar Queries gesehen, deren Planung langsam war (über 100 ms), sodass ein Cache nützlich gewesen sein könnte, aber ich erinnere mich an keinen Fall, in dem die Optimierung wirklich zwingend nötig gewesen wäre
Das ist den hier vorgestellten Stencils ähnlich, aber der Code lag meist nur in einer einzigen Instanz vor, daher wurden nur selten Kopien erzeugt
Zum Beispiel nutzte auch Doom unter DOS solche Optimierungstechniken. Um in engen Rendering-Loops auf alten CPUs genug Leistung herauszuholen, war das nötig