Die Softwareentwicklungslandschaft ist reif für neue Programmiersprachen - und Optimierungen für bestehende. Rust, Swift, Kotlin oder das experimentelle Python-Derivat Mojo sind nur einige Beispiele von vielen für Sprachen, die Entwicklern mehr Flexibilität in Sachen Geschwindigkeit, Sicherheit, Komfort, Portabilität und Performance ermöglichen. In diesem Zusammenhang stellen neue Programmier-Tools einen wichtigen Faktor dar - insbesondere Compiler. Das wichtigste Exemplar dieser Gattung ist das Open-Source-Projekt LLVM, das ursprünglich von Chris Lattner, Schöpfer der Programmiersprache Swift, an der Universität von Illinois entwickelt wurde.
LLVM erleichtert es nicht nur, neue Programmiersprachen zu erstellen, sondern auch, bereits bestehende weiterzuentwickeln. Dazu bietet es Tools, die einige der undankbarsten Tasks automatisieren. Beispielsweise:
einen Compiler zu entwickeln,
Code auf verschiedene Plattformen und Architekturen zu portieren,
architekturspezifische Optimierungen zu generieren oder
Code zu schreiben, um etwa Exceptions zu händeln.
Seine liberale Lizenzierung ermöglicht es, LLVM als Softwarekomponente frei wiederzuverwenden oder das Compiler-Framework als Service bereitzustellen.
LLVM definiert
Im Kern ist LLVM eine Bibliothek, um programmatisch maschinennativen Code zu erstellen. Entwickler können seine API verwenden, um Anweisungen in einem Format namens Intermediate Representation (IR) zu erzeugen. Dieses Format kompiliert LLVM entweder in eine eigenständige Binärdatei - oder führt eine Just-in-Time (JIT)-Kompilierung des Codes durch, um ihn im Kontext eines anderen Programms auszuführen, beispielsweise eines Interpreters oder einer Laufzeitumgebung.
Die LLVM-APIs bieten Primitives, um viele gängige Strukturen und Muster zu entwickeln, die in Programmiersprachen zu finden sind. Zum Beispiel setzt fast jede Sprache auf das Konzept von Funktion und globaler Variable, viele auch auf Coroutines und C-Foreign-Function-Interfaces. Erstgenanntes ist bei LLVM standardmäßiges Element der IR, für zweiteres kommen Metaphern zum Einsatz.
Um LLVM zu verstehen, kann eine Analogie zur Programmiersprache C hilfreich sein: Die wird manchmal als portierbare High-Level-Assemblersprache beschrieben. Was daran liegt, dass C über Konstruktionen verfügt, die sich gut auf Systemhardware abbilden lassen. Deswegen wurde es auch auf fast jede Systemarchitektur portiert. Allerdings ist C als portable Assemblersprache nur bis zu einem gewissen Punkt nützlich - schließlich wurde es nicht für diesen speziellen Zweck entwickelt.
Das sieht bei IR anders aus: Es ist nativ als portierbare Assemblersprache konzipiert. Ein Weg zu dieser Portabilität führt über Primitives, die nicht von einer bestimmten Maschinenarchitektur abhängen. Integer-Types sind beispielsweise nicht auf die maximale Bitbandbreite der zugrundeliegenden Hardware (etwa 32- oder 64-Bit) beschränkt: Sie können Primitive Integer-Types mit beliebig vielen Bits erstellen. Sie müssen sich auch keine Gedanken darüber machen, Output erzeugen zu müssen, der mit dem Befehlssatz eines bestimmten Prozessors matcht - das übernimmt LLVM für Sie. Das architekturneutrale Design von LLVM ermöglicht dabei Support für alle gegenwärtigen und künftigen Arten von Hardware.
Live-Beispiele von LLVM IR können Sie auf der "Godbolt Compiler Explorer"-Website kurzerhand selbst erstellen, indem Sie Ihren C- oder C++-Code entsprechend übersetzen.
Auf der Liste der Sprachen, die LLVM verwenden, finden sich viele bekannte Namen:
Swift verwendet LLVM als Compiler-Framework,
Rust verwendet es als Kernkomponente seiner Toolchain.
Auch diverse Compiler verwenden LLVM, darunter:
der C/C++-Compiler Clang,
die .NET-Implementierung Mono, die eine Option bietet, um nativen Code mit einem LLVM-Backend zu kompilieren und
Kotlin, das eine Compiler-Technologie namens Kotlin/Native bietet, die LLVM nutzt, um in maschinennativen Code zu komilieren.
Wie Programmiersprachen LLVM nutzen
Der häufigste Anwendungsfall für LLVM ist die Verwendung als Ahead-of-Time (AOT)-Sprachcompiler. Das Clang-Projekt setzt beispielsweise auf AOT-Kompilierung, um C und C++ in native Binärdateien kompilieren. LLVM ermöglicht aber auch andere Dinge.
Just-in-Time-Kompilierung mit LLVM
In manchen Situationen ist es nötig, Code "on the fly" zur Laufzeit zu generieren, anstatt ihn vorab zu kompilieren. Die Programmiersprache Julia JIT-kompiliert ihren Code beispielweise, um schnell zu laufen und weil sie mit dem Benutzer über eine REPL (read-eval-print-Loop) oder einen interaktiven Prompt interagieren muss.
Numba, ein Mathematik-Beschleunigungs-Package für Python, kompiliert ausgewählte Python-Funktionen im JIT-Verfahren in Maschinencode. Es kann auch Numba-dekorierten Code im Voraus kompilieren, Python bietet aber (wie Julia) Rapid Development, weil es eine interpretierte Sprache ist. Eine JIT-Kompilierung ergänzt in einem solchen Fall den interaktiven Workflow von Python besser als eine AOT-Kompilierung.
Darüber hinaus wird auch mit neuen Möglichkeiten experimentiert, LLVM als JIT-Compiler zu verwenden, etwa bei der Kompilierung von PostgreSQL-Abfragen - was eine maximale Leistungssteigerung um den Faktor Fünf in Aussicht stellt.
Automatische Code-Optimierung mit LLVM
LLVM kompiliert nicht nur die IR in nativen Maschinencode. Sie können es auch programmatisch anweisen, den Code granular zu optimieren - und zwar während des gesamten Linking-Prozesses. Die Optimierungen können recht aggressiv sein und umfassen Dinge wie das Inlining von Funktionen, die Beseitigung von totem Code (einschließlich ungenutzter Typdeklarationen und Funktionsargumente) und Loop Unrolling.
Auch hier liegt die Stärke darin, dass man all das nicht selbst implementieren muss. LLVM kann diese Aufgaben für Sie erledigen, oder Sie können das Compiler-Framework anweisen, sie je nach Bedarf ein- und auszuschalten. Wenn Sie zum Beispiel für kleinere Binärdateien etwas Performance opfern wollen, können Sie LLVM über Ihr Compiler-Frontend anweisen, das Loop Unrolling zu deaktivieren.
Domänenspezifische Sprachen mit LLVM
LLVM ist auch nützlich, um sehr spezifische Programmiersprachen zu erstellen, die sich exklusiv einer Problemdomäne widmen. In gewisser Weise ist das der Bereich, wo LLVM am meisten glänzen kann, weil es einen großen Teil der Plackerei bei diesem Unterfangen beseitigt und sicherstellt, dass das Ergebnis funktioniert.
Das Emscripten-Projekt zum Beispiel ermöglicht es, LLVM-IR-Code in JavaScript zu konvertieren. Das erlaubt theoretisch jeder Sprache mit einem LLVM-Backend, Code zu exportieren, der in Browsern ausgeführt werden kann. Eines der langfristigen Ergebnisse dieser Arbeit sind LLVM-basierte Backends, die WebAssembly erzeugen können und es Sprachen wie Rust ermöglichen, direkt in WASM zu kompilieren.
Eine weitere Möglichkeit, LLVM zu nutzen, ist es, domänenspezifische Erweiterungen zu einer bestehenden Sprache hinzuzufügen. Nvidia hat LLVM etwa verwendet, um den CUDA-Compiler zu erstellen. Dieser ermöglicht, nativen Support für CUDA hinzuzufügen,
Der Erfolg von LLVM mit domänenspezifischen Sprachen hat neue Projekte angestoßen, die sich mit den in diesem Rahmen aufgeworfenen Problemen befassen. Das Größte: Einige domänenspezifische Sprachen sind nur schwer in LLVM IR zu übersetzen, ohne dabei viel Arbeit am Frontend aufzuwerfen. Eine mögliche Lösung hierfür könnte zukünftig das Multi-Level Intermediate Representation (MLIR)-Projekt darstellen. MLIR bietet bequeme Möglichkeiten, um komplexe Datenstrukturen und -operationen darzustellen, die dann automatisch in LLVM IR übersetzt werden können. Beispielsweise könnten viele der komplexen Datenflussgraphen-Operationen des ML-Frameworks TensorFlow mit MLIR effizient in nativen Code kompiliert werden.
Mit LLVM arbeiten
Der typische Weg, mit LLVM zu arbeiten, führt über Code in einer Programmiersprache, die LLVMs Bibliotheken unterstützt. Zwei gängige Sprachen in diesem Bereich sind C und C++. Viele LLVM-Entwickler entscheiden sich aus guten Gründen für eine dieser beiden Sprachen:
LLVM selbst ist in C++ geschrieben.
Die APIs von LLVM sind in C- und C++-Inkarnationen verfügbar.
Die Programmiersprachenentwicklung tendiert dazu, C/C++ als Basis zu nutzen.
Dennoch sind diese beiden nicht die einzige Wahl - viele Sprachen können nativ C-Bibliotheken aufrufen. Es ist jedoch hilfreich, wenn eine Bibliothek in der Sprache vorliegt, die die APIs von LLVM elegant verpackt. Glücklicherweise weisen viele Programmiersprachen und Sprachlaufzeiten solche Bibliotheken auf, zum Beispiel:
Wenn Sie neugierig sind, wie Sie LLVM-Bibliotheken verwenden können, um eine Sprache zu erstellen, empfiehlt sich dieses Tutorial, das Sie wahlweise mit C++ oder OCAML durch die Erstellung einer einfachen Sprache namens Kaleidoscope führt. Das wurde inzwischen auch auf andere Sprachen portiert, nämlich:
Was LLVM nicht kann
Bei allem, was LLVM bietet, ist es nützlich zu wissen, was es nicht kann:
LLVM parst nicht die Grammatik einer Sprache. Viele Werkzeuge erledigen diese Aufgabe bereits, wie lex/yacc, flex/bison, Lark und ANTLR. Da Parsing ohnehin von der Kompilierung entkoppelt werden soll, ist es nicht verwunderlich, dass LLVM nicht versucht, dieses Problem zu lösen.
LLVM geht auch nicht direkt auf die größere Softwarekultur rund um eine bestimmte Sprache ein. Die Installation der Binärdateien des Compilers, Package Management und die Aktualisierung der Toolchain müssen Sie selbst erledigen.
Es gibt immer noch allgemeine Teile von Sprachen, für die LLVM keine Primitives bereitstellt. LLVM bietet keinen Garbage-Collector-Mechanismus, stellt jedoch Tools zur Verfügung, um Garbage Collection zu implementieren. Für die Zukunft ist es angesichts der Enticklunsggeschwindigkeit von LLVM nicht ausgeschlossen, dass ein nativer Mechanismus für Garbage Collection implementiert wird.
Dieser Beitrag basiert auf einem Artikel unserer US-Schwesterpublikation Infoworld.