Wie JIT-Compiler dynamische Sprachen schnell machen
Wie JIT-Compiler Ruby, Python und JavaScript beschleunigen, indem sie überflüssige Operationen entfernen, mit Beispielen aus YJIT und ZJIT.

Ruby hat den Ruf, langsam zu sein. Python auch. JavaScript auch, zumindest hatte es ihn, bis V8 es schnell genug machte, um serverseitige Workloads zu stemmen. Die Geschichte, wie dynamische Sprachen schnell werden, ist die Geschichte der JIT-Kompilierung (Just-In-Time), und sie gehört zu den faszinierendsten Bereichen der praktischen Informatik. Das neueste Kapitel: Rubys ZJIT entfernt auf der Ebene der Zwischenrepräsentation überflüssige Lade- und Schreibvorgänge für Objekte, dieselbe Art von Optimierung, die TurboFan von V8 so effektiv gemacht hat.
Wenn du dich jemals gefragt hast, warum dein Ruby-Code nur einen Bruchteil der Geschwindigkeit von C erreicht oder wie JavaScript schnell genug wurde, um VS Code anzutreiben, liegt die Antwort darin, zu verstehen, was JIT-Compiler tatsächlich tun und warum die Optimierung dynamischer Sprachen grundsätzlich schwieriger ist als die statischer.
Das grundlegende Problem dynamischer Sprachen
Wenn ein C-Compiler a + b sieht, kennt er die Typen von a und b zur Kompilierzeit. Sind beide Ganzzahlen, erzeugt er einen einzigen ADD-Befehl. Sind es Fließkommazahlen, erzeugt er eine Fließkomma-Addition. Die CPU führt diesen Befehl in einem Takt aus. Es gibt keine Mehrdeutigkeit und keine Entscheidung zur Laufzeit.
Wenn ein Ruby-Interpreter a + b sieht, weiß er fast nichts. a könnte eine Ganzzahl, eine Fließkommazahl, ein String, ein Array oder jedes Objekt sein, das eine +-Methode definiert. Der Interpreter muss den Typ von a prüfen, die +-Methode für diesen Typ finden, den Typ von b prüfen, gegebenenfalls Typen umwandeln, Sonderfälle behandeln (Überlauf, eingefrorene Objekte) und schließlich die Operation ausführen. Dieses einzelne + kann Dutzende Befehle, Speicherzugriffe und Verzweigungen umfassen.
# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+) # Hash lookup
raise NoMethodError unless method # Branch
if a.is_a?(Integer) && b.is_a?(Integer) # Type checks
result = integer_add(a.value, b.value) # Actual math
if overflow?(result) # Overflow check
result = promote_to_bignum(result) # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b]) # Generic dispatch
end
result
end
Dieser Overhead (Typprüfung, Methodensuche, Dispatch) ist der Preis der Dynamik. Die Flexibilität, a + b zu schreiben und es für Ganzzahlen, Fließkommazahlen, Strings und eigene Objekte funktionieren zu lassen, kostet zur Laufzeit viel.
Wie JIT-Kompilierung dagegenhält
Die Aufgabe eines JIT-Compilers ist es, zu beobachten, was das Programm zur Laufzeit tatsächlich tut, und auf Basis dieser Beobachtungen optimierten Maschinencode zu erzeugen. Die zentrale Erkenntnis: Ruby-Code könnte mit jedem Typ arbeiten, in der Praxis sieht eine bestimmte Aufrufstelle aber fast immer dieselben Typen.
Wurde sum(a, b) 10.000 Mal aufgerufen und waren a und b immer Ganzzahlen, kann der JIT spezialisierten Maschinencode erzeugen, der annimmt, dass sie es weiterhin sind. Er gibt einen einzelnen Ganzzahl-Additionsbefehl aus, versehen mit einem „Guard“, einer schnellen Typprüfung, die auf den langsamen Pfad zurückfällt, falls die Annahme jemals verletzt wird.
Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result
Das ist ein Prozess aus 14 Schritten, der auf 5 Schritte reduziert wird, wobei die Schritte 1, 2 und 4 jeweils einfache Vergleichsbefehle sind. Die eigentliche Berechnung, das ADD, benötigt einen CPU-Takt. So wurde JavaScript von „zu langsam für alles Ernsthafte“ zu „schnell genug für eine vollwertige IDE“.
Rubys JIT-Weg: Von MJIT über YJIT zu ZJIT
Rubys Geschichte mit der JIT-Kompilierung zeigt eindrucksvoll, wie schwierig dieses Problem ist. Ruby hat mehrere JIT-Implementierungen durchlaufen, die jeweils einen anderen Ansatz verfolgen.
MJIT (Ruby 2.6, 2018) übersetzte Ruby-Bytecode in C-Code und ließ diesen dann von GCC oder Clang kompilieren. Das ergab gut optimierten Code, hatte aber eine miserable Aufwärmphase, denn C zu kompilieren dauert Sekunden, nicht Millisekunden. Bis der JIT-kompilierte Code bereit war, war das Programm unter Umständen schon fertig.
YJIT (Ruby 3.1, 2022) war ein Beitrag von Shopify, zuerst in C geschrieben und später in Rust neu implementiert. YJIT nutzt eine Technik namens „lazy basic block versioning“: Es kompiliert Code jeweils einen Basisblock nach dem anderen, nur dann, wenn dieser Code tatsächlich ausgeführt wird, und erstellt spezialisierte Versionen basierend auf den beobachteten Typen. Das ergibt eine schnelle Aufwärmphase (Millisekunden statt Sekunden) bei guter Spitzenleistung. YJIT verbessert die Ruby-Performance bei realen Workloads wie Rails-Anwendungen typischerweise um 15–30 %.
ZJIT ist die nächste Entwicklungsstufe, und hier wird es richtig spannend. ZJIT führt eine Zwischenrepräsentation (IR) ein, eine strukturierte Darstellung des Programms zwischen Bytecode und Maschinencode. Diese IR ermöglicht klassische Compiler-Optimierungen, die der direkte Ansatz von YJIT, von Bytecode direkt zu Maschinencode, nicht ohne Weiteres leisten konnte.
Überflüssige Lade- und Schreibvorgänge eliminieren
Die konkrete Optimierung, die ZJIT kürzlich eingeführt hat, das Entfernen überflüssiger Lade- und Schreibvorgänge für Objekte, klingt esoterisch, hat aber enorme Auswirkungen. Hier ist der Grund.
Ruby-Objekte speichern ihre Instanzvariablen in einer Property-Tabelle. Jedes Mal, wenn du @name liest, lädt der Interpreter den Wert aus der Property-Tabelle des Objekts im Speicher. Jedes Mal, wenn du @name = value schreibst, speichert er in diese Tabelle. In einer Methode, die dieselbe Instanzvariable mehrfach anspricht, lädt der Interpreter sie jedes Mal erneut aus dem Speicher, denn im allgemeinen Fall könnte sich der Wert zwischen zwei Lesevorgängen geändert haben.
class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor # Load @width, multiply, store @width
@height = @height * factor # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }
Mit einer IR kann ZJIT Load-Elimination durchführen: Wurde @width bereits geladen und hat danach nichts den Wert verändert, wird der Wert aus einem Register wiederverwendet, statt erneut aus dem Speicher geladen zu werden. Ebenso kann es Store-Elimination durchführen: Schreibst du zweimal hintereinander in @width, ohne dass dazwischen jemand liest, kann der erste Schreibvorgang entfernt werden.
Diese Optimierungen sind in statischen Sprach-Compilern Standard, GCC und LLVM beherrschen sie seit Jahrzehnten. Bei einer dynamischen Sprache ist das aber deutlich schwieriger, wegen Aliasing. In Ruby könnte der Aufruf einer beliebigen Methode potenziell die Instanzvariablen jedes Objekts verändern (etwa über instance_variable_set, method_missing oder Trace-Hooks). Der JIT muss beweisen, dass sich @width zwischen zwei Lesevorgängen nicht verändern konnte, und dafür muss er analysieren, was jede dazwischenliegende Operation tun könnte.
Der Vorteil der IR
Dass ZJIT eine IR eingeführt hat (und warum V8s TurboFan, JavaScriptCores DFG/FTL und HotSpots C2 alle IRs nutzen), liegt daran, dass sich diese Optimierungen dadurch kombinieren lassen. Eine IR ist im Kern ein Graph aus Operationen, auf den Optimierungen als Graphtransformationen angewendet werden können.
- Konstantenfaltung: Sind beide Operanden einer Addition zur Kompilierzeit bekannt, ersetze die Operation durch ihr Ergebnis.
2 + 3wird zur Kompilierzeit zu5. - Entfernung von totem Code: Wird das Ergebnis einer Operation nie verwendet, wird sie vollständig entfernt.
- Eliminierung gemeinsamer Teilausdrücke: Taucht dieselbe Berechnung zweimal auf, wird sie nur einmal berechnet und das Ergebnis wiederverwendet.
- Load-/Store-Elimination: Entferne redundante Speicheroperationen, wie oben beschrieben.
- Escape-Analyse: Wird ein Objekt erzeugt und verlässt die aktuelle Methode nie, wird es auf dem Stack statt auf dem Heap alloziert (oder die Allokation wird ganz eliminiert).
- Inlining: Ersetze einen Methodenaufruf durch den Methodenrumpf, wodurch sich für die anderen Optimierungen mehr Ansatzpunkte ergeben.
Diese Optimierungen verstärken sich gegenseitig. Das Inlining einer Methode macht ihre Operationen im Kontext des Aufrufers sichtbar, was konstante Werte offenbaren kann, die Konstantenfaltung ermöglicht, wodurch Code tot wird und entfernt wird. Eine einzige Inlining-Entscheidung kann so zur Entfernung Dutzender Operationen führen.
Das Sicherheitsnetz der Deoptimierung
Alles, was ein JIT-Compiler tut, ist spekulativ. Er nimmt an, dass sich Typen nicht ändern, Methoden nicht neu definiert werden und Monkey-Patching seinen optimierten Code nicht ungültig macht. Wenn diese Annahmen brechen, muss der JIT „deoptimieren“, also den optimierten Code verwerfen und auf den Interpreter zurückfallen.
Deoptimierung gehört zu den schwierigsten Teilen des JIT-Designs. Der optimierte Code hat unter Umständen lokale Variablen eliminiert, Operationen umgeordnet oder tief verschachtelte Aufrufe inline eingebettet. Um zurück zum Interpreter zu wechseln, muss der JIT den Interpreterzustand rekonstruieren, also alle lokalen Variablen, den Aufrufstack und den Programmzähler, aus dem, was der optimierte Code noch zur Verfügung hat. Dafür sind Metadaten nötig (als „On-Stack-Replacement“- oder OSR-Maps bezeichnet), die optimierte Codezustände auf Interpreterzustände abbilden.
Wenn Deoptimierung häufig auftritt, ein Zustand, der „Deopt-Thrashing“ heißt, kann die Performance schlechter sein als reine Interpretation. Der JIT verbringt Zeit damit, optimierten Code zu kompilieren, ihn kurz auszuführen, zu deoptimieren und das Ganze zu wiederholen. V8 begegnet dem, indem es Deoptimierungszähler führt und die Optimierung einer bestimmten Funktion irgendwann aufgibt. YJIT geht einen einfacheren Weg: Es erzeugt mehrere Versionen jedes Codepfads für unterschiedliche Typkombinationen. Das verringert den Bedarf an Deoptimierung, kostet aber mehr erzeugten Code.
Warum das über Ruby hinaus wichtig ist
Rubys JIT-Weg spiegelt wider, was bei dynamischen Sprachen insgesamt passiert. Pythons Copy-and-Patch-JIT (eingeführt in CPython 3.13) ist der erste Schritt hin zu einer echten JIT-Kompilierung für Python. LuaJIT ist seit Jahren bemerkenswert schnell, dank eines aggressiven Tracing-JIT. Der JIT von PHP 8.0+ nutzt LLVM für seine IR-basierten Optimierungen.
Das Muster ist konsistent: Man beginnt mit einem Interpreter, fügt Profiling hinzu, um das Laufzeitverhalten zu verstehen, kompiliert heiße Pfade mit Typspezialisierung, führt eine IR für klassische Optimierungen ein und verfeinert alles weiter. Jede Sprache steht vor denselben Herausforderungen, etwa dynamischem Dispatch, veränderlichen Objekten, eval und Monkey-Patching, und findet ähnliche Lösungen.
Für Entwickler, die diese Sprachen nutzen, ist die praktische Erkenntnis: Die Performance-Lücke zwischen dynamischen und statischen Sprachen schrumpft. Sie wird nie ganz verschwinden, denn Typprüfungen und Guards kosten weiterhin etwas, und Deoptimierung ist ein inhärenter Overhead. Ein gut optimierter JIT kann bei rechenintensiven Workloads aber auf das 2- bis 5-Fache der Laufzeit äquivalenten C-Codes kommen, und das ist für die allermeisten Anwendungen „schnell genug“.
Die subtilere Erkenntnis: Schreib unkomplizierten Code. JIT-Compiler optimieren vorhersagbare Muster. Monomorphe Aufrufstellen (an denen eine Methode immer dieselben Typen erhält) lassen sich gut optimieren. Polymorphe Aufrufstellen (mit wechselnden Typen) sind schwieriger. Megamorphe Aufrufstellen (Dutzende Typen) werden womöglich nie optimiert. Code, der für einen Menschen leicht zu verstehen ist, ist meist auch für einen JIT leicht zu optimieren, eine schöne Übereinstimmung der Interessen.


