Eval of Julia code
Einer der schwierigsten Teile beim Lernen, wie die Julia-Sprache Code ausführt, ist das Verständnis, wie all die Teile zusammenarbeiten, um einen Codeblock auszuführen.
Jeder Codeabschnitt durchläuft typischerweise viele Schritte mit potenziell unbekannten Namen, wie (in keiner bestimmten Reihenfolge): flisp, AST, C++, LLVM, eval
, typeinf
, macroexpand
, sysimg (oder Systembild), Bootstrapping, kompilieren, parsen, ausführen, JIT, interpretieren, boxen, unboxen, intrinsische Funktion und primitive Funktion, bevor er in das gewünschte Ergebnis (hoffentlich) umgewandelt wird.
Julia Execution
Die 10.000-Fuß-Perspektive des gesamten Prozesses ist wie folgt:
- Der Benutzer startet
julia
. - Die C-Funktion
main()
auscli/loader_exe.c
wird aufgerufen. Diese Funktion verarbeitet die Befehlszeilenargumente, füllt die Strukturjl_options
aus und setzt die VariableARGS
. Anschließend initialisiert sie Julia (indem siejulia_init
ininit.c
aufruft, was möglicherweise ein zuvor kompiliertes sysimg lädt. Schließlich übergibt sie die Kontrolle an Julia, indem sieBase._start()
aufruft. - Wenn
_start()
die Kontrolle übernimmt, hängt die nachfolgende Befehlsfolge von den übergebenen Befehlszeilenargumenten ab. Wenn beispielsweise ein Dateiname angegeben wurde, wird es fortfahren, diese Datei auszuführen. Andernfalls wird es eine interaktive REPL starten. - Die Details darüber, wie der REPL mit dem Benutzer interagiert, lassen wir weg. Sagen wir einfach, dass das Programm am Ende mit einem Block von Code endet, den es ausführen möchte.
- Wenn der Codeblock, der ausgeführt werden soll, in einer Datei ist, wird
jl_load(char *filename)
aufgerufen, um die Datei zu laden, und parse wird ausgeführt. Jedes Fragment des Codes wird dann aneval
übergeben, um es auszuführen. - Jedes Codefragment (oder AST) wird an
eval()
übergeben, um Ergebnisse zu erzeugen. eval()
führt jedes Codefragment aus und versucht, es injl_toplevel_eval_flex()
auszuführen.jl_toplevel_eval_flex()
entscheidet, ob der Code eine "Toplevel"-Aktion (wieusing
odermodule
) ist, die innerhalb einer Funktion ungültig wäre. Falls ja, übergibt es den Code an den Toplevel-Interpreter.jl_toplevel_eval_flex()
dann expands der Code, um alle Makros zu eliminieren und den AST zu "vereinfachen", um ihn einfacher auszuführen.jl_toplevel_eval_flex()
verwendet dann einige einfache Heuristiken, um zu entscheiden, ob der AST JIT-compiliert oder direkt interpretiert werden soll.- Der Großteil der Arbeit zur Interpretation von Code wird von
eval
ininterpreter.c
behandelt. - Wenn stattdessen der Code kompiliert wird, wird der Großteil der Arbeit von
codegen.cpp
erledigt. Jedes Mal, wenn eine Julia-Funktion zum ersten Mal mit einem bestimmten Satz von Argumenttypen aufgerufen wird, wird type inference für diese Funktion ausgeführt. Diese Informationen werden vom Schritt codegen verwendet, um schnelleren Code zu generieren. - Schließlich verlässt der Benutzer die REPL oder das Ende des Programms wird erreicht, und die Methode
_start()
gibt zurück. - Kurz vor dem Verlassen ruft
main()
jl_atexit_hook(exit_code)
auf. Dies ruftBase._atexit()
auf (das alle Funktionen aufruft, die inatexit()
innerhalb von Julia registriert sind). Dann ruft esjl_gc_run_all_finalizers()
auf. Schließlich räumt es allelibuv
-Handles ordentlich auf und wartet, bis sie geleert und geschlossen sind.
Parsing
Der Julia-Parser ist ein kleines Lisp-Programm, das in Femtolisp geschrieben ist. Der Quellcode dafür ist in Julia unter src/flisp verteilt.
Die Schnittstellenfunktionen dafür sind hauptsächlich in jlfrontend.scm
definiert. Der Code in ast.c
behandelt diesen Übergang auf der Julia-Seite.
Die anderen relevanten Dateien in diesem Stadium sind julia-parser.scm
, die das Tokenisieren von Julia-Code und die Umwandlung in einen AST behandelt, und julia-syntax.scm
, die die Umwandlung komplexer AST-Darstellungen in einfachere, "niedrigere" AST-Darstellungen behandelt, die besser für die Analyse und Ausführung geeignet sind.
Wenn Sie den Parser testen möchten, ohne Julia vollständig neu zu erstellen, können Sie das Frontend wie folgt eigenständig ausführen:
$ cd src
$ flisp/flisp
> (load "jlfrontend.scm")
> (jl-parse-file "<filename>")
Macro Expansion
Wenn eval()
auf ein Makro trifft, erweitert es diesen AST-Knoten, bevor es versucht, den Ausdruck auszuwerten. Die Makroerweiterung umfasst einen Übergang von 4d61726b646f776e2e436f64652822222c20226576616c28292229_40726566
(in Julia) zur Parserfunktion jl_macroexpand()
(geschrieben in flisp
) zum Julia-Makro selbst (geschrieben in - was sonst - Julia) über fl_invoke_julia_macro()
und zurück.
Typischerweise wird die Makroerweiterung als erster Schritt während eines Aufrufs von Meta.lower()
/jl_expand()
aufgerufen, obwohl sie auch direkt durch einen Aufruf von macroexpand()
/jl_macroexpand()
aufgerufen werden kann.
Type Inference
Typinferenz wird in Julia durch typeinf()
in compiler/typeinfer.jl
implementiert. Typinferenz ist der Prozess, bei dem eine Julia-Funktion untersucht wird, um Grenzen für die Typen ihrer Variablen sowie Grenzen für den Typ des Rückgabewerts der Funktion zu bestimmen. Dies ermöglicht viele zukünftige Optimierungen, wie das Unboxing bekannter unveränderlicher Werte und das Hoisting von verschiedenen Laufzeitoperationen zur Compile-Zeit, wie das Berechnen von Feldoffsets und Funktionszeigern. Die Typinferenz kann auch andere Schritte wie die Konstantenweitergabe und Inlining umfassen.
JIT Code Generation
Codegen ist der Prozess, bei dem einen Julia AST in nativen Maschinencode umgewandelt wird.
Die JIT-Umgebung wird durch einen frühen Aufruf von jl_init_codegen
in codegen.cpp
initialisiert.
Auf Anfrage wird eine Julia-Methode durch die Funktion emit_function(jl_method_instance_t*)
in eine native Funktion umgewandelt. (Hinweis: Bei der Verwendung des MCJIT (in LLVM v3.4+) muss jede Funktion in ein neues Modul JIT-kompiliert werden.) Diese Funktion ruft rekursiv emit_expr()
auf, bis die gesamte Funktion ausgegeben wurde.
Ein Großteil des verbleibenden Inhalts dieser Datei ist verschiedenen manuellen Optimierungen spezifischer Code-Muster gewidmet. Zum Beispiel weiß emit_known_call()
, wie man viele der primitiven Funktionen (definiert in builtins.c
) für verschiedene Kombinationen von Argumenttypen inline einfügen kann.
Andere Teile von Codegen werden von verschiedenen Hilfsdateien behandelt:
Behandelt Backtraces für JIT-Funktionen
Behandelt die ccall- und llvmcall-FFI sowie verschiedene
abi_*.cpp
-Dateien.Behandelt die Emission verschiedener niederstufiger intrinsischer Funktionen
System Image
Das Systemabbild ist ein vorkompiliertes Archiv einer Reihe von Julia-Dateien. Die mit Julia verteilte sys.ji
-Datei ist ein solches Systemabbild, das durch die Ausführung der Datei sysimg.jl
und die Serialisierung der resultierenden Umgebung (einschließlich Typen, Funktionen, Module und aller anderen definierten Werte) in eine Datei generiert wird. Daher enthält es eine eingefrorene Version der Module Main
, Core
und Base
(und alles andere, was sich am Ende des Bootstrappings in der Umgebung befand). Dieser Serializer/Deserializer wird von jl_save_system_image
/jl_restore_system_image
in staticdata.c
implementiert.
Wenn keine sysimg-Datei vorhanden ist (jl_options.image_file == NULL
), bedeutet dies auch, dass --build
in der Befehlszeile angegeben wurde, sodass das endgültige Ergebnis eine neue sysimg-Datei sein sollte. Während der Julia-Initialisierung werden minimale Core
- und Main
-Module erstellt. Dann wird eine Datei namens boot.jl
aus dem aktuellen Verzeichnis ausgewertet. Julia wertet dann jede Datei aus, die als Befehlszeilenargument angegeben ist, bis das Ende erreicht ist. Schließlich speichert es die resultierende Umgebung in einer "sysimg"-Datei, die als Ausgangspunkt für einen zukünftigen Julia-Durchlauf verwendet werden kann.