Funktionsorientiert und schnell: Die Programmiersprache Julia

Julia ist eine flexible und performante Programmiersprache, die unterschiedliche Konzepte verbindet. Trotz ihrer wissenschaftlichen Ausrichtung eignet sie sich auch für allgemeine Entwickleraufgaben.

In Pocket speichern vorlesen Druckansicht 31 Kommentare lesen
Funktionsorientiert und schnell: Die Programmiersprache Julia
Lesezeit: 20 Min.
Von
  • Simon Danisch
Inhaltsverzeichnis

Die Programmiersprache Julia ist relativ jung. Sie vereint die Vorteile dynamischer und statischer Sprachen und soll die Geschwindigkeit von C/C++ erreichen, dabei aber einen mit Python vergleichbar geringem Programmieraufwand erfordern. Zusätzlich bietet Julia von Lisp inspirierte Metaprogrammierung sowie unterschiedliche Wege, Code parallel und verteilt auszuführen. Die Standardbibliothek bindet bewährte Open-Source-Bibliotheken für wissenschaftliches Programmieren ein und implementiert viele Funktionen direkt. Julia ist merklich auf wissenschaftliche Aufgaben fokussiert, eignet sich aber auch für generelle Programmieraufgaben. Durch ihren Git-basierten Paketmanager lassen sich Pakete auf einfache Weise ausprobieren und erweitern. Das erlaubt Entwicklern einen schnellen Einstieg.

Historisch ist Julia aus der Notwendigkeit geboren, eine schnelle und einfach nutzbare Sprache für wissenschaftliches und numerisches Programmieren zu schaffen. Sie ist als Reaktion auf den Trend zu sehen, einen mathematisch korrekten Prototypen in einer expressiven Sprache wie Python zu entwickeln und ihn, wenn er zu langsam läuft, teilweise in Fortran beziehungsweise C/C++ neu zu schreiben. Dieses Muster zeigt sich besonders in Anwendungsbereichen, die auf gute Performance angewiesen sind, wie Machine Learning, Statistik und Simulationen. Durch diese Vorgehensweise können sich Entwickler zwar recht einfach auf die korrekte Implementierung der Algorithmen konzentrieren, der Preis ist jedoch der erhöhte Zeitaufwand und die kompliziertere Wartung. Julia verspricht eine elegante Umsetzung innerhalb einer Programmiersprache. Da das System zur Laufzeit (JIT, Just-in-time) mit LLVM kompiliert, dem Compiler-Backend von Clang für C/C++, schafft sie exzellente Auto-SIMD-Vektorisierungen und höchste Performance. SIMD-Vektorisierung ist besonders wichtig, um moderne Prozessoren auszulasten. Sie erlaubt es, mehrere CPU-Instruktionen gleichzeitig auszuführen. Dieses Compiler-Feature verlangt rigide Laufzeitgarantien und ist deswegen in kaum einer dynamischen Sprache vorzufinden.

Daher lassen sich sogar die performancekritischen Teile der Standardbibliothek in Julia schreiben. Kombiniert mit der freien MIT-Lizenz ist es somit einfach, Julia zu erweitern und für die eigene Nutzung anzupassen. Das belegt die Zahl der Mitarbeitenden (604 beim Schreiben dieses Artikels) auf der GitHub-Seite.

Julia ist nicht strikt objektorientiert, sondern bietet als Sprachkonstrukte Typen an, die in der einfachsten Form Structs in C ähneln. Der Methoden-Dispatch bezieht alle Typen der Signatur mit ein und es lassen sich abstrakte und konkrete Typen frei kombinieren. Dieses Prinzip ist als Multiple Dispatch bekannt. Dass Funktionen in Julia First-Class-Objekte sind, ermöglicht die funktionale Programmierung. Beim Verzicht auf Selbstreferenzierung (wie this in Java) sind aber auch viele Konzepte aus der objektorientierten Welt nutzbar.

Julia bietet neben struct-artigen Typen Abstrakte, Bits, Union und Tupel. Die unterschiedlichen Typen lassen sich folgendermaßen nutzen:

# Ein mutierbarer Typ mit Typ-Parametern
mutable struct Point{N, T}
values::NTuple{N, T} # Tuple-Typ mit homogenen elementen
end
# ein bits-Typ mit einer beliebigen Menge an 'raw bits' 
# Erben kann ein Typ von abstrakten Typen
# und wird mit <: symbolisiert
bitstype 8 SmallBool2 <: Integer
# 2 abstrakte Typen, von denen man erben kann
abstract type Number end
abstract type Real <: Number end
# Ein nicht mutierbares struct, 
# das oft Stack-allokiert werden kann
# Parameter T ist auf Reals begrenzt,
# und Complex selbst erbt von Number
struct Complex{T <: Real} <: Number
real::T
imaginary::T
end
# definiere eine Funktion für die Typen.
# Deklaration der Typparameter ist optional,
# sie können aber mit der where-Syntax deklariert werden
function plus(z::Complex, w::Complex{T}) where T <: AbstractFloat
Complex(z.real + w.real, z.imag + w.imag)
end
# man kann auch Funktionen auf abstrakten Typen
# definieren (hier in Kurzform):
wer_bin_ich(x::Number) =
println("Ich bin eine Zahl mit typ: ", typeof(x))

Ein Großteil der Performance ist dem Typensystem geschuldet, das dabei hilft, spezialisierten Code zu erzeugen. Beispielsweise lässt sich der Code für Array-Typen Array{ElementTyp, Dimensionalität} auf die passenden Parameter spezialisieren.

Da die Typenparameter innerhalb einer Funktion konstante Ausdrücke sind, lässt sich der sie verwendende Code oft komplett statisch eliminieren (constant folding). Das gilt sogar beim Einsatz von Reflexionen. Daher kann der Compiler folgenden Code optimieren:

function constant_fold(A::Array{T, N}) 
x = T(1) # konvertiere zum element-Typ
return x + (typeof(A) == Array{Float32, 2} ? 1f0 : 2f0
end
# ein weiteres Werkzeug zur Inspektion von Julia-Code,
# das die LLVM-Zwischenrepresentation (LLVM-IR)
# einer Methode anzeigt:
julia>array = rand(Float32, 2, 2) # erstellt ein
# 2D-Float32-Array
# mit zufälligen Zahlen
julia> @code_llvm constant_fold(array)
define float @julia_constant_fold_71694(%jl_value_t*) #0 {
top:
ret float 3.000000e+00
}


Hier ist erkennbar, dass der komplette Funktionskörper eines Array{Float32, 2} zu return 3f0 reduziert wird. Dasselbe funktioniert sogar ohne jegliche Typendeklaration. Letztere ist optional und hilft vor allem für Dispatch, Dokumentation und um der Inferenz auf die Sprünge zu helfen.

Zwei herausragende Eigenschaften von Julia sind die Shell-Anbindung und die Fähigkeit, C-Bibliotheken ohne Performanceverlust aufzurufen. Das folgende Beispiel reicht über die Shell einen Julia-String zu einem Compiler weiter und bindet die kompilierte Bibliothek in Julia ein:

c_code = """
#include <stddef.h>
double c_sum(size_t n, double *X) {
double s = 0.0;
for (size_t i = 0; i < n; ++i) {
s += X[i];
}
return s;
}
"""
const Clib = tempname() # gibt einen Dateinamen 
# im temp-Verzeichnis zurück
shell_command = 
`gcc -fPIC -O3 -msse3 -xc -shared -o $(Clib * "." * Libdl.dlext) -`
# do-Syntax erstellt eine anonyme Funktion, 
# die an erster Stelle des Funktionsaufrufes übergeben wird
open(shell_command, "w") do pipe
print(pipe, c_code) # printe den Code in die Pipe,
# um ihn an GCC zu übergeben
end
# erstelle eine Julia-Funktion, die die C-Funktion aufruft
c_sum(X::Vector{Float64}) =
ccall(("c_sum", Clib),
Float64, # Rückgabetyp
(Csize_t, Ptr{Float64}), # Argumententypen
length(X), X) # Argumente

Eine vergleichbare Julia-Funktion sollte nun die gleiche Performance liefern wie die C-Funktion.

Julia bietet eine breite Werkzeugpalette zur Introspektion und Metaprogrammierung. Dazu gehören Lisp-ähnliche Makros, mit denen sich Julia-Ausdrücke syntaktisch umformen lassen. Ein Julia-Makro ist somit ein Mapping von einem Ausdruck zu einem neuen.

macro time_it(in_expressions)
# ein quote-Block erlaubt es, Julia-Code zu schreiben
# und diesen als Ausdruck zu erhalten
out_expression = quote
x = time()
$(in_expressions) # man kann andere Ausdrücke
# mit $() einfügen ('splicen')
println("elapsed time: ", time() - x)
end
return out_expression
end
# Makros werden mit einem @-Zeichen aufgerufen
# und brauchen keine Klammern.
@time_it my_function_call() # alle validen Julia-Ausdrücke 
# sind erlaubt

Makros funktionieren rein syntaktisch. Entwickler haben innerhalb des Makros keinen direkten Zugriff auf Typinformationen. Um Code auf Typen zu spezialisieren, gibt es in Julia das Konzept der sogenannten "Generated Functions". Diese arbeiten wie normale Funktionen, aber beim ersten Aufruf erhält man statt der Argumente nur die Typen und kann dann einen Ausdruck zurückgeben, der zum Funktionskörper wird:

@generated function tuple_map(f::Function, 
x::NTuple{N, Values})
where {N, Values}
# man kann auch manuell einen Ausdruck
# ohne quote-Block formen
# in diesem Fall rollt man die Anwendung der Funktion f
# auf die Elemente des Tupels aus.
expr = Expr(:tuple)
for i = 1:N
push!(expr.args, :( f(x[ $i ] )))
end
println(expr) # print zum Debuggen, um den
# entstandenen Ausdruck zu zeigen
return expr
end
>tuple_map(sqrt, (1.0, 4.0, 9.0)) == (1.0, 2.0, 3.0)
printed: (f(x[1]), f(x[2]), f(x[3]))
# beim zweiten Aufruf wurde die Funktion schon 
# kompiliert und println(expr) wird nicht mehr ausgeführt
>tuple_map(sqrt, (1.0, 4.0, 9.0))

Auf diese Weise lässt sich Julia zum Skripten des Compilers benutzen, was sich derzeit viele Entwickler für hochperformante Bibliotheken für Datenbanken und Tensor-Algebra wünschen. Das erspart ihnen die aufwendige Kombination einer Skriptsprache mit einem Compiler wie LLVM oder GCC.

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.

Julia 1.0 sollte eigentlich zur JuliaCon im Juni 2017 fertig sein. Leider konnten die Macher den Zeitplan nicht einhalten und haben stattdessen Julia 0.6 veröffentlicht. Ein Großteil der geplanten Features für Julia 1.0 sind jedoch in Version 0.6 enthalten. Das kommende 0.7-Release soll ohne Breaking Changes auskommen und Warnungen für Code ausgeben, der nicht 1.0-kompatibel ist. Programme, die mit Julia 0.7 ohne Warnungen laufen, sind im Umkehrschluss kompatibel zum 1.0-Release, das nach derzeitiger Planung im vierten Quartal diesen Jahres erscheinen soll.

Stefan Karpinski spricht auf der JuliaCon 2016 über die Pläne für Julia 1.0.

Viele Funktionen der Standardbibliothek sollen bis zum Release von Julia 1.0 in eigenständigen Paketen landen. Damit wird Julia im Kern schlanker, und die einzelnen Pakete können unterschiedliche Update-Zyklen haben. Damit Julia trotzdem benutzerfreundlich bleibt, will das Team verstärkt auf Metapakete setzen, die mehrere Pakete kombinieren und sicherstellen, dass das Zusammenspiel funktioniert.

Unter anderem um die oben beschriebenen Metapakete zu realisieren, wird es eine neue Version des Paketmanagers als Pkg 3 geben, dessen Alphaversion bereits zur JuliaCon 2017 erschienen ist. Er soll stabiler sein, besser Performance bieten und besser Lösungen für getrennte Namensbereiche bringen.

Vortrag zu Julias neuem Paketmanager Pkg3

Pläne und Wünsche

Julia bietet noch kein Paket, das auf Augenhöhe mit dem beliebten DataFrame-Typ der Programmiersprache R ist. Aufgrund seiner großen Bedeutung für die statistische Arbeit hat die Weiterentwicklung der Infrastruktur eine hohe Priorität. Ein großes Problem bei statistischen Daten ist die häufige Arbeit mit fehlenden Werten – außerdem können Einträge mehrere Typen haben. Das lässt sich in Julia als Union-Typ darstellen, der in Version 0.6 noch schlechte Performance bietet. Die Entwickler arbeiten aktiv an der Lösung des Problems, die sie teilweise bereits im Master Branch umgesetzt haben.

Ein weiteres notwendiges Sprachkonstrukt sind effiziente Named Tuples, die unter anderem zum Einsatz kommen, um zeilenorientierte Datenbanken zu implementieren. Julia-Entwickler können mit ihnen zudem den schnellen Dispatch von Funktionen mit Keyword-Argumenten implementieren – deren mangelnde Performance führt immer wieder zu Klagen in der Entwickler-Community.

Wenn ein Objekt nie eine Funktion verlässt, muss der Garbage Collector sie nicht verfolgen und kann sie potenziell auf dem Stack allokieren. Die Optimierung ist unter anderem wichtig, um einen Array-View-Typ ohne Performanceverlust zu implementieren. Eine andere Anwendung ist das Verpacken von Immutables in mutierbaren Typen. Mit verbesserter Escape-Analyse kann der Compiler diesen Vorgang komplett eliminieren, sodass sich teilweise die Vorteile von Immutable- und Mutable-Typen kombinieren lassen.

Im Moment cached die Toolchain von Julia nur die ersten Teile der Compiler-Pipeline, speichert aber weder den LLVM-IR noch den kompilierten binären Code ohne weiteren Aufwand. Das führt dazu, dass Entwickler beim ersten Aufruf von Funktionen auf das Kompilieren warten müssen. Außerdem können sie keine Binary erstellen, die auf den JIT-Compiler verzichtet erstellen, was für eingebettete Geräte oder das Einbinden von Julia-Bibliotheken in andere Sprachen erstrebenswert wäre. Manuell ist das bereits seit geraumer Zeit möglich, und die Integration in die Compiler-Infrastruktur steht weit oben auf der Prioritätenliste.

Julia läuft schon seit der Version 0.3 erstaunlich stabil und bügelt in Version 0.6 endlich die größten PerformanceProbleme und Inkonsistenzen aus.

Die für Julia 1.0 geplanten Erweiterungen wie der Paketmanager und das Kompilieren ausführerbarer Programme, die auf JIT-Kompilierung verzichten können runden das Bild hoffentlich bis Ende 2017 ab. Für Version 1.0 sind aber nicht nur die neuen Funktionen wichtig, sondern auch das Versprechen, ein stabiles Interface für die kommenden Jahre anzubieten, was die Voraussetzung für einen langfristigen Erfolg von Julia in der Industrie ist.

Simon Danisch
ist der Autor von GPUArrays.jl, GLVisualize.jl, Visualize.jl und Transpiler.jl. Er hat einen Hintergrund in Computer Vision in C++ und Java und entschied sich, dass es für numerische und grafische Programmierung eine besser performanceorientierte Sprache geben muss. Zwischen 2012 und 2013 gewann Julia an Popularität, und seitdem arbeitet er als Freelancer an der Graphik- und GPU-Infrastruktur in Julia, unter anderem für das JuliaLab am MIT.
(rme)