JavaScript Engines: Performance-Steigerung der Browser

Seite 4: Der JIT-Compiler

Inhaltsverzeichnis

Zunächst stellt sich die Frage, wie man nachträglich einen Compiler integrieren könnte: Schnelle Vorbereitung oder Ausführung?

Dass der Compiler eine gewisse Zeit benötigt, um eine Anwendung zu kompilieren, ist das erste Problem. Der Interpreter hingegen muss nur den Quelltext parsen und kann sofort mit der Ausführung Zeile für Zeile starten. Beim Compiler sieht das jedoch anders aus, er benötigt wesentlich länger. Benutzer würden dadurch beim Öffnen einer Website warten müssen, bis der Compiler fertig ist. Liegt beispielsweise eine komplette Angular-Anwendung mit allerlei zusätzlichen Bibliotheken vor, dauert das einige Sekunden.

Deshalb helfen sich Engine-Entwickler mit einem kleinen Trick. Sie kombinieren das Beste aus beiden Welten und verwenden den schnellen Programmstart eines Interpreters und die schnelle Ausführung des vom Compiler generierten Maschinencodes. Aus der Vogelperspektive betrachtet, führt der Interpreter jede Anwendung beim Start aus. Das heißt für Benutzer, dass sie die Anwendung schnell bedienen können. Parallel dazu kompiliert und optimiert die Engine den Quellcode. Dabei müssen Anwender nicht warten, bis das gesamte Programm in kompilierter Form vorliegt. Vielmehr kann auf die in Maschinencode vorliegenden Teile zugegriffen werden.

Je nach Engine unterscheiden sich die Strategien. Es kann zum Beispiel sein, dass eine Engine erst ein Profil der Anwendung erstellt und auf das Profil hin optimierten Programmcode erzeugt. Oder aber sie kompiliert den kompilierten Code über mehrere Phasen immer wieder neu und wird nach jeder Kompilierung schneller.

Wie entscheidet nun die Engine, welche Codeteile sie optimieren soll? Während der Interpreter fleißig seine Tätigkeit verrichtet, beobachtet ihn die Engine. Codeteile, die er häufig ausführt, kommen als Kandidaten für den Compiler in Frage. Die Bezeichnung dieser Teile lautet "Hot Code (Path)". Folglich greift der Compiler nur bei Bedarf ein. Deswegen ist das Vorgehen auch unter "Just-in-Time Compilation" bekannt.

Es kann also durchaus passieren, dass während der Laufzeit einer Webapplikation derselbe Quellcode sowohl interpretiert als auch in unterschiedlichen Versionen in Form von Maschinencode ausgeführt wird.

Nach dem Betrachten der ersten, folgt nun die zweite "Schwachstelle": Dynamische Typisierung. Hierbei greifen Engines auf eine Technik namens Inline Caches zurück. Die Grundannahme bei Inline Caches ist, dass immer nur dieselben Typen beispielsweise als Parameter bei Funktionsaufrufen übergeben werden. Ob das der Fall ist oder nicht, ermittelt die Engine während der Erstellung des Profils beziehungsweise beim Beobachten des Programmablaufs.

Stellt sich heraus, dass tatsächlich immer wieder dieselben Typen vorhanden sind, schreibt die Engine den Code dahingehend um, dass der Zugriff auf die Property oder die Funktion ohne langwierige Typüberprüfung erfolgt. Wie bei einer statischen Typisierung ist somit die Speicheradresse bekannt und direkt aufrufbar.

Die Engine muss sich für den Fall absichern, dass plötzlich ein anderer Typ auftritt. Deshalb baut sie vor dem Zugriff einen Typcheck ein. Sollte es sich nicht um einen Typen handeln, der im Inline Cache existiert, findet eine Deoptimierung statt und sie interpretiert den Programmteil bis es wieder zu einer Kompilierung kommt.

Ab wann teilt die Engine nun zwei Objekte demselben Typ zu? In V8 gilt die Regel, dass das nur dann der Fall ist, wenn exakt die gleichen Properties vorhanden sind und sie in derselben Reihenfolge definiert sind. Die folgenden Deklarationen erzeugen deshalb zwei unterschiedliche Typen:

const han = {firstname: "Han", lastname: "Solo"};
const luke = {lastname: "Skywalker", firstname: "Luke"};

Man beachte, dass zwar dieselben Properties definiert sind, jedoch nicht in derselben Reihenfolge. Die Gründe liegen ganz einfach darin, dass die Properties in der internen Speicherstruktur anders angeordnet sind und sich dadurch an unterschiedlichen Speicheradressen befinden.