Bytecode-VM
Der Standardausführungspfad ist ein Tree-Walker: Er besucht jeden AST-Knoten und gibt einen Wert zurück. Bei häufig aufgerufenen Funktionen wird der Dispatch-Aufwand pro Knoten zum Engpass. Phasis ergänzt diesen Pfad um einen spekulativen Compiler, der Bytecode in PHP-Closures umwandelt und so den Aufwand senkt.
Wann die VM aktiv wird
Jede JsFunction hat einen Zähler für häufige Aufrufe. Sobald er einen Schwellenwert überschreitet (standardmäßig 100), versucht die Engine, den Funktionskörper über src/Bytecode/JsToPhp.php in eine PHP-Closure zu kompilieren.
Die Kompilierung gelingt, wenn der Funktionskörper zu einer bekannten Gruppe von Formen passt:
- Zuweisungen an lokale Variablen mit vorhersagbaren Typen (Zahl, Objekt, Funktion, Array, Zeichenfolge).
- Arithmetik mit Zahlen.
- Lesezugriffe auf Member von lokalen Variablen mit Objekttyp UND auf
this(this.v,obj.x). - Methodenaufrufe auf lokalen Variablen mit Objekttyp (
obj.method(args)), bei denen der Empfänger als$thisValueübergeben wird. - Aufrufe von
new Ctor(args)-Konstruktoren — der Typ der lokalen LHS-Variablen wird aufobjecthochgestuft, und ein Bail-out schützt vor Rückgaben ohne Objekttyp. for- /while-Schleifen mit einfachen Bedingungsformen.returnmit berechenbarem Ausdruck.if/elsemit typisierten Bedingungen.
Die Kompilierung schlägt fehl (und die Funktion bleibt beim Tree-Walker), wenn der Funktionskörper Folgendes enthält:
with-Anweisungen.try / catch / finally(wird vom Tree-Walker verarbeitet, um die korrekte Abschlusssemantik sicherzustellen).eval()-Aufrufe.- Direkte Mutation von
arguments. - Alles, was der Compiler noch nicht umwandeln kann.
Nach erfolgreicher Kompilierung leitet die Engine den Aufruf der Funktion über die kompilierte Closure. Folgende Aufrufe führen nativen PHP mit dem Aufwand eines einzigen Funktionsaufrufs aus, statt mit einem Aufruf pro AST-Knoten.
Bail-outs
Der kompilierte Schnellpfad beruht auf Annahmen (Zahlen bleiben Zahlen, Eigenschaften behalten dieselbe Form). Wird eine Annahme zur Laufzeit verletzt, wirft die Closure ein Bailout, und die Ausführung startet für diesen Aufruf im Tree-Walker neu.
Hier ist die Spezifikationskonformität besonders wichtig. Der Tree-Walker muss den Aufruf, bei dem der Bail-out ausgelöst wurde, von Anfang an erneut ausführen können, ohne Seiteneffekte des fehlgeschlagenen Schnellpfads zu beobachten. Phasis bewältigt das auf zwei Arten:
- Seiteneffektfreie Präfixe — der Compiler sucht vor jeder Spekulation nach Seiteneffekten. Wenn die Funktion vor dem Spekulationspunkt E/A ausführt (console.log, Host-Funktionen), unterdrückt der Schnellpfad diese Ausführung nicht, sondern schließt sie ab und fährt fort.
- Ablehnung zur Kompilierzeit — bei Formen, für die ein Zurückrollen sichtbarer Seiteneffekte nötig wäre (eine nicht numerische
let r = fn(), bei derfn()den Zustand verändert), verweigert der Compiler die Kompilierung vollständig. Die Funktion bleibt beim Tree-Walker.
Der zugrunde liegende Fall: Eine nicht numerische Zuweisung führt auf dem Schnellpfad fn() aus, wirft Bailout und wird anschließend im Tree-Walker erneut ausgeführt — dabei läuft fn() zweimal und verursacht zwei Sätze von Seiteneffekten. Durch die Ablehnung zur Kompilierzeit wird verhindert, dass fn() überhaupt auf dem Schnellpfad läuft; so sieht der Tree-Walker nur den einzigen Aufruf.
VM-Dispatch (die zweite Ebene)
Einige Funktionen werden zu PHP-Closures kompiliert; andere verwenden stattdessen einen noch einfacheren Bytecode-Interpreter in src/Bytecode/VM.php. Der Bytecode ist ein flaches Array aus Opcodes (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), die über einen switch-Dispatch verarbeitet werden.
Die VM wird für Funktionen verwendet, deren Kompilierung zu einer PHP-Closure sich nicht lohnt, die aber oft genug aufgerufen werden, um vom Überspringen der AST-Durchläufe zu profitieren. Methodenaufrufe auf integrierten Objekten (Array.prototype.map, String.prototype.split, …) nutzen Schnellpfade in der VM, die den generischen callFunction-Trampolinaufruf umgehen.
Benutzerdefinierter Aufrufstack
Op::CALL, Op::CALL_METHOD und Op::NEW_CALL verwenden einen Inline-Aufrufpfad, der den gesamten JS-Aufrufstack innerhalb von VM::execute hält – ohne bei jedem JS-Aufruf einen neuen PHP-Frame anzulegen. Beim Eintritt wird der Zustand des Aufrufers (pc, code stream, operand stack, locals, env, this, strict-mode flag) in einem pro VM geführten savedFrames-Pool aus SavedFrame-Instanzen abgelegt, und die Dispatch-Schleife richtet ihre lokalen Variablen auf den Aufgerufenen aus. Op::RET entnimmt den gespeicherten Frame und stellt den Zustand wieder her. Ausnahmen werden über gespeicherte Frames hinweg abgewickelt, bevor auf den Tree-Walker zurückgegriffen wird.
Die Eignung wird in JsFunction zwischengespeichert: vmInlineCallCache für reguläre Aufrufe (BC-kompilierter Funktionskörper, nicht an bind() gebunden, nicht nativ), vmInlineConstructCache für new-Ausdrücke (zusätzlich isConstructable, nicht abgeleitet, ohne Initialisierer für Instanzfelder oder private Methoden). Alles andere läuft weiterhin über den kanonischen callFunction / vmNewExpression-Trampolin.
Der architektonische Nebeneffekt: Die Obergrenze des PHP-Stacks begrenzt die Rekursionstiefe von JS nicht mehr. Nur noch CallStack::maxDepth setzt ein Limit — auf 4 096. Das liegt deutlich über jedem legitimen test262-Fall (die meisten benötigen weniger als 2 k Frames) und ist dennoch eng genug, damit Tests mit pathologischer Endlosrekursion innerhalb des Zeitlimits des Runners schnell fehlschlagen. Der transpilierte Closure-Pfad (JsToPhp) hat für den nicht eingebundenen Fallback eine eigene Schranke von 8 192 Frames.
Frame-Snapshot-Generatoren
Einfache Generator-Bodies – ohne yield*, ohne yield innerhalb von try/catch/finally und ohne await – werden zu Bytecode kompiliert. Der Compiler gibt einen neuen Op::YIELD-Opcode aus, an dem der Body pausiert; Op::YIELD erfasst den Dispatch-Zustand (cf, pc+1, stack, sp, locals, env, thisValue, strict) in einem GeneratorSnapshot-Heap-Objekt und gibt ein YieldResult-Sentinel von VM::execute zurück. Der ausführende JsGenerator speichert den Snapshot und übergibt ihn beim nächsten next() / throw() an VM::execute zurück; dabei wird der Fortsetzungswert an der Spitze des Operand-Stacks ersetzt.
Dies ersetzt den PHP-Fiber-Pfad für die unterstützte Teilmenge. Der Fiber-basierte Treiber (executeGeneratorBody) bleibt für Fälle bestehen, die der Compiler ablehnt – yield*-Delegation, Generatoren, die innerhalb eines try-Blocks Werte liefern, Async-Generatoren und Async-Funktionen mit await. JsGenerator arbeitet in zwei Modi: Bei jedem next() / throw() / return() wird geprüft, ob die Instanz einen Snapshot oder einen Fiber hat, und entsprechend weitergeleitet.
Es gibt keinen im Benchmark einstellbaren Parameter; die Eignung wird zur Compile-Zeit bestimmt und auf JsFunction zwischengespeichert. Im Snapshot-Pfad entfällt pro Yield ein PHP-Kontextwechsel; der Fiber-Pfad bleibt für alle übrigen Fälle unverändert.
Inline-Caches
Op::LOAD_MEMBER enthält einen Inline-Cache pro PC, der nach Prototyp-Identität indiziert ist und für das Muster „Prototyp-Methode auf Instanz“ verwendet wird (c.sum(...), arr.push(...), jeder Dispatch einer Klassenmethode). Trefferbedingung: Der Empfänger hat keinen eigenen Slot für den Namen, die zwischengespeicherte Prototyp-Referenz ist identisch mit dem getPrototype() des Empfängers und PropertyMap::$version des Prototyps entspricht weiterhin der erfassten Version.
Die Cache-Invalidierung läuft über PropertyMap::$version. Strukturelle Änderungen (set / delete) erhöhen den Wert bedingungslos. Überschreibungen von Daten-Slots erhöhen ihn nur, wenn PropertyMap::$isUsedAsProto wahr ist – ein Write-Barrier-Flag, das beim ersten Erkennen des Objekts als [[Prototype]] eines anderen Objekts gesetzt wird. Instanzobjekte, die nie als Prototypen dienen, verursachen bei jedem obj.x = v keine Kosten für die IC-Infrastruktur.
Getter-Accessoren werden nie zwischengespeichert (sie müssen bei jedem Lesezugriff erneut ausgeführt werden).
PropertyMap enthält einen zweiten Zähler, $mutationVersion, der bei jedem Schreibzugriff bedingungslos erhöht wird (ohne Write-Barrier). Der JSON.stringify-Cache speichert diesen Zähler für jedes besuchte verschachtelte Objekt/Array, damit eine reine Instanzänderung wie o.a = 99 den zwischengespeicherten String korrekt invalidiert, selbst wenn die $version des ICs die Erhöhung verhindert hätte.
JSON.stringify-Cache
JsObject enthält $jsonCacheString + $jsonCacheVersions (eine Liste von [obj, mutationVersion]-Paaren für jedes beim trivialen Stringify-Durchlauf besuchte verschachtelte Objekt). Beim nächsten Aufruf von JSON.stringify(o) ohne Replacer / ohne Leerzeichen prüft der triviale schnelle Pfad, ob alle gespeicherten Versionen unverändert sind, und gibt den zwischengespeicherten String zurück, ohne die Objekte erneut zu durchlaufen oder zu kodieren. Häufige Deep-Clone-Muster wie JSON.parse(JSON.stringify(o)) erzielen beim Stringify-Anteil etwa die doppelte Geschwindigkeit.
Bei einem Cache-Miss (eine gespeicherte Version hat sich geändert) wird der Cache stillschweigend verworfen und beim nächsten Durchlauf neu gefüllt. Objekte mit toJSON, Gettern, Accessoren, Symbolen oder [[PrimitiveValue]]-Slots weichen auf den spezifikationsgetreuen Serialisierer aus und befüllen den Cache nie – der Cache-Pfad verarbeitet daher ausschließlich strukturell einheitliche Daten.
Schnelle Pfade für integrierte Funktionen
Die größten Performance-Gewinne kamen daher, dass die VM gezielt auf bestimmte integrierte Funktionen optimiert wurde:
Atomics.load/store/compareExchangemit kooperativer Zeitplanung überloadSpinHook/storeNotifyHook– dadurch wurden 4 Atomics-Spin-Loop-Tests wiederhergestellt.Date.prototype.getTimezoneOffsetmit einem direkten Hashmap-DST-Cache – 45 % schneller bei den DST-Stresstests von SpiderMonkey.String.fromCharCodemit einem Inline-ASCII-Schnellpfad – spart etwa 1 M Dispatches pro typischem Durchlauf.decodeURI/decodeURIComponentmit einem ultraschnellen 12-Zeichen-%XX%XX%XX%XX-Pfad, derhex2bin-Massendekodierung verwendet.Object.keys/Object.values/Object.entriesmit direktem Zugriff auf Property-Maps.Array.prototype.map/filter/forEach/reducemit nativer Iteration über das zugrunde liegende PHP-Array.
Jeder schnelle Pfad wird durch eine „Shape-Prüfung“ abgesichert – wenn der Empfänger nicht die erwartete Form hat (e.g. etwa eine TypedArray-Ansicht, die nicht auf einem einfachen PHP string basiert), fällt die VM auf den spezifikationsgetreuen langsamen Pfad zurück.
Was dies nicht leistet
Die Bytecode-VM ist kein JIT im Sinne von V8. Sie erzeugt keinen nativen Maschinencode pro Funktion und spekuliert nicht anhand beobachteter Typen – abgesehen von der monomorphen Prototyp-Identitätsprüfung des ICs. Das Inlining an Funktionsgrenzen wird durch den benutzerdefinierten Callstack angenähert (kein zusätzlicher PHP-Frame pro JS-Aufruf), nicht dadurch, dass der Body des Callee in den Bytecode des Callers überführt wird.
Der JsToPhp-Transpiler kommt einer JIT-Engine am nächsten: Er erzeugt PHP-Quellcode für häufig aufgerufene Funktionskörper, den die Tracing-JIT von PHP 8.5 anschließend in nativen Code kompiliert. JsToPhp läuft über eval() und verursacht daher einmalige Kompilierungskosten. Außerdem muss die Tracing-JIT die Funktion wie nutzerdefinierten Code erst erkennen. Wenn der eval()-Schritt entfällt und direkt Opcodes für opcache ausgegeben werden, lässt sich der Abstand weiter verringern – siehe Benchmarks § Roadmap.
Benchmark
bench/microbench.js führt gut ein Dutzend kleine Benchmarks aus (loop-arith, loop-fib, fn-recurse, fn-deep-recurse, obj-create, obj-prop, proto-method, gen-iterate, arr-push, arr-map, str-concat, str-split-join, json-roundtrip, closure, destructure). Median der Laufzeit:
php bench/run.php
Die aktuellen Zahlen sind nach jedem bench-Workflow-Lauf in BENCH.md festgeschrieben. Sowohl der bench- als auch der test262-Matrix-Workflow werden manuell gestartet.