Funktionsorientiert und schnell: Die Programmiersprache Julia

Seite 3: Geheimnis der Geschwindigkeit

Inhaltsverzeichnis

Julia hat einen globalen (außerhalb von Funktionen) und einen lokalen (innerhalb von Funktionen) Scope. Ersterer ist komplett dynamisch, während der lokale Scope einer Methode statisch in Abhängigkeit von den Argumenten der Funktion ist. Innerhalb einer Funktion lässt sich der Code komplett auf die Funktionsargumente spezialisieren, wodurch es möglich ist, die Typen vollständig zu erschließen (infer). Solange sie zur Aufrufzeit bekannt sind, können somit Optimierungen wie Inlining zum Einsatz kommen.

Da Julia den LLVM-Compiler nutzt, ist die Geschwindigkeit vergleichbar mit einem in Clang (LLVMs C++-Frontend) kompilierten Programm. Entwickler können jedoch von einer Funktion aus auf den globalen Scope zugreifen, Ausdrücke evaluieren oder bewusst Code schreiben, den der Compiler nicht vollständig erschließen kann. In solchen Fällen kann Julia nicht mehr mit der vollen Typeninformation arbeiten und muss weniger optimierten Code kompilieren.

Die Instabilität der Typen kann sich innerhalb eines Funktionskörpers fortpflanzen und bringt dynamische Methoden und Typen-Lookup mit sich. Das Verhalten breitet sich jedoch nicht auf aufgerufenen Funktionen aus, da diese wieder auf die Funktionsargumente spezialisiert sein dürfen. Dieses als "Function Barrier"

bezeichnete Muster ist eine verbreitete Technik, um dynamischen Code mit hochperformanten Funktionen zu kombinieren.

Die nächstgrößere Auswirkung auf Julias Performance hat der Garbage Collector (GC). Entgegen der üblichen Annahme ist für numerischen Code ein GC oft ein Performancegewinn. Im Vergleich hat Reference Counting einen konstanten Overhead für alle Operationen, und sogar in purem C kann ein malloc relativ langsam im Vergleich zu einer Allokation mit dem GC sein, da Letzterer die Allokationen und Freigabe von dem Speicher besser in Batches optimieren kann.

Offensichtlich gibt es aber auch Anwendungen, bei denen sich der GC negativ auf die Performance auswirkt. Wenn ein Programm beispielsweise viele kleine Objekte allokieren muss, kann es schnell passieren, dass es die meiste Zeit damit verbringt, den "Müll" aufzuräumen. In solchen Fällen schlägt sich Julia mit einem recht simplen GC nicht sonderlich gut im Vergleich zu einer Sprache wie Java, deren Macher seit Jahren den Garbage Collector optimieren. Im Unterschied zu anderen Programmiersprachen können Julia-Entwickler die Situation jedoch gut vermeiden, indem sie beispielsweise Stack-Allokation nutzen oder bereits allokierten Speicher wiederverwenden. Auf diese Weise können sie vollständig auf GC-abhängige Allokationen verzichten. Das ermöglicht das Erstellen von Software mit niedriger Latenz, was unter anderem für Virtual Reality oder der Programmierung von Robotern notwendig ist.

Ein weniger offensichtlicher Grund für die gute Performance von Julia liegt in der hohen Produktivität der Entwickler. Sie können mit Metaprogrammierung, cleveren Abstraktionen und Generated Functions mit wenig Zeitaufwand komplexe Optimierungen implementieren. Aufgrund der stets begrenzten Ressourcen kann das zu einem wichtigen Faktor werden, der einen großen Einfluss darauf hat, wie schnell eine Bibliothek am Ende wirklich läuft.

PyCall ist eine der Kernbibliotheken, die es einer jungen Sprache wie Julia ermöglicht, auf Pythons umfangreiches Ökosystem an Bibliotheken zuzugreifen. PyCall ist sehr stabil, ermöglicht performanten Datenaustausch und kann sogar NumPy-Arrays ohne Kopien zu Julia weiterreichen. Außerdem können Entwickler Funktionen austauschen und Python-Code direkt von Julia aus evaluieren und ausführen. Durch PyCall hat Julia Zugriff auf Bibliotheken wie IJulia (interaktive Notebooks mit Inline-Plots), Matplotlib (beliebte Plotting-Bibliothek) und viele andere Pakete, die es in der Stabilität und Qualität noch nicht nativ in Julia gibt.

JuMP ist eine mehrfach ausgezeichnete Open-Source-Modellierungssprache, die für eine weite Bandbreite an Optimierungsproblemen anwendbar ist (linear, mixed-integer, quadratisch, konisch-quadratisch, semidefinit and nonlinear). Sie implementiert mit Julias Makros eine algebraische Sprache, mit der sich komplexe Probleme formulieren und mit zeitgemäßer Performance lösen lassen. JuMP nutzt Julias C-Call, um moderne Solver nahtlos zu integrieren, die sie mit performantem Julia-Code ergänzt.

Celeste ist ein Projekt zum Verarbeiten und Katalogisieren astronomischer Daten. Es ist das erste große Julia-Projekt, das auf einem Top-5-Supercomputer (NERSC's Cori17) läuft. Celeste ist in einer Kooperation zwischen Forschern von Julia Computing, UC Berkeley, Intel, the National Energy Research Scientific Computing Center (NERSC), Lawrence Berkeley National Laboratory und JuliaLabs@MIT entstanden.

Es zeigt, dass Julia fit ist, auf hochgradig paralleler Hardware zu laufen. Celeste ist das erste Projekt, das mit einer dynamischen Sprache die Petaflop-Marke geknackt hat. Das zeigt, dass Julia in der Lage ist, den neuen Xeon Phi mit extrabreiten Vector Instructions (SIMD 512), vielen Cores und den Interconnects im Supercomputer auszulasten. Dazu nutzten die Entwickler Julias volles Potenzial aus, darunter das Abrollen von Operationen auf kleinen statischen Vektoren, Julias abstraktes Array-Interface, um Algorithmen von den Speicherzugriffsmustern zu trennen, exzessives Inlining und LLVMs Auto-Vektorisierung. Details dazu gab es in der Celeste-Keynote auf der JuliaCon 2017 zu hören.

Julia bietet zusätzlich Pakete, um für GPUs zu kompilieren. Das ausgereifteste ist CUDAnative.jl, das aber leider nur für Nvidia-Hardware kompilieren kann. Es nutzt die Tatsache, dass Julia LLVM verwendet und für LLVM-IR ein Compiler existiert, der Code für Nvidia-Grafikkarten erzeugen kann.

Einen weiteren Ansatz verfolgt der vom Autor dieses Artikels entwickelte Transpiler.jl, der die Fähigkeit von Julia nutzt, zu jeder Funktionen einen typisierten Abstract Syntax Tree (AST) zu erhalten. Diesen kann Transpiler.jl frei manipulieren, um ihn in eine andere Sprache zu übersetzen. Da Julias spezialisierter und Typen-inferierter Code recht ähnlich zu C ist, klappt das gut mit GPU-Sprachen wie OpenGL-GLSL oder OpenCL-C, die Transpiler.jl unterstützt. Somit lassen sich Grafikanwendungen in Julia schreiben und dabei alle Berechnungen auf die GPU verlagern. Auf die Weise hält Julias Vielfalt an Abstraktionen in die sonst recht schlichte Welt der GPU-Programmierung Einzug. Außerdem vereinfacht es das Debuggen der Funktionen.

Eines der ersten Pakete, die Transpiler.jl für die Kompilierung von OpenGL-Code benutzt, ist Visualize.jl. Das Paket ist beim Verfassen dieses Artikels noch ein Prototyp, aber komplett in Julia geschrieben. Es kann Grafiken mit OpenGL auf der GPU, aber auch komplett auf der CPU berechnen. Eine der herausragenden Merkmale ist es, dass Visualize.jl alle möglichen Funktionen auf die GPU verlagern kann, sodass Entwickler die Darstellung der Grafiken einfacher erweitern können.

Ein anderes Paket, das auf CUDAnative.jl und Transpiler.jl setzt, ist GPUArrays.jl. Dabei handelt es sich um eine N-dimensionale Array-Bibliothek, die unter anderem Maps, MapReduces, Wrapper für CL/CU-BLAS und CL/CU-FFT sowie weitere Funktionen für lineare Algebra auf der GPU führen kann. Außerdem zielt sie darauf ab, ein vereinheitlichtes Ausführungsmodell von Julia-Code auf der GPU zu etablieren, damit es einfacher wird, Code für eine breite Palette an Hardware zu schreiben.

Das funktioniert nicht nur für Funktionen innerhalb von GPUArrays, sondern auch für zahlreiche von Nutzern oder aus der Standardbibliothek. Beispielsweise lassen sich mit GPUArrays und ForwardDiff.jl automatisch gradiente Funktionen berechnen und Arithmetik mit komplexen Zahlen auf der GPU berechnen, ohne speziell auf Grafikchips ausgelegten Code zu erfordern.

Dass Entwickler ohne großen Aufwand Code auf der GPU ausführen können, ist nicht nur ein schönes Plus, sondern kann ganz real entscheiden, ob ein Projekt durchführbar ist oder nicht. Eines der ersten Projekte, das von GPUArrays profitiert, ist die Implementierung der Poincaré-Abbildung eines chaotischen neuronalen Netzwerks in Julia. Die Implementierung lässt sich fast unverändert auf die GPU verlagern, was die Berechnung von sieben Minuten auf fünf Sekunden reduziert hat.