Przejdź do treści

Maszyna wirtualna bajtkodu

Domyślną ścieżką wykonywania jest walker drzewa — odwiedza każdy węzeł AST i zwraca wartość. W przypadku często wywoływanych funkcji, uruchamianych miliony razy, wąskim gardłem jest narzut obsługi każdego węzła. Phasis dodaje do tego spekulacyjny kompilator bajtkodu do domknięć PHP, aby ograniczyć ten narzut.

Kiedy uruchamia się maszyna wirtualna

Każde JsFunction ma licznik częstości wywołań. Gdy przekroczy próg (domyślnie 100), silnik próbuje skompilować treść funkcji do domknięcia PHP za pomocą src/Bytecode/JsToPhp.php.

Kompilacja się powiedzie, jeśli treść funkcji pasuje do znanego zestawu kształtów:

  • Przypisania do zmiennych lokalnych o przewidywalnych typach (numerycznym, obiektowym, funkcyjnym, tablicowym, tekstowym).
  • Operacje arytmetyczne na liczbach.
  • Odczyt właściwości obiektowych zmiennych lokalnych ORAZ this (this.v, obj.x).
  • Wywołania metod na zmiennych lokalnych typu obiektowego (obj.method(args)), z odbiornikiem przekazywanym jako $thisValue.
  • Wywołania konstruktorów new Ctor(args) — promują typ zmiennej lokalnej po lewej stronie do object i zabezpieczają przed wynikiem niebędącym obiektem.
  • Pętle for / while z prostymi warunkami.
  • return z wyrażeniem, które można obliczyć.
  • if / else z typowanymi warunkami.

Kompilacja kończy się niepowodzeniem (a funkcja pozostaje obsługiwana przez walker drzewa), gdy jej treść zawiera:

  • Instrukcje with.
  • try / catch / finally (obsługiwane przez walker drzewa, aby zapewnić prawidłową semantykę zakończenia).
  • Wywołania eval().
  • Bezpośrednią mutację arguments.
  • Dowolny element, którego kompilator nie potrafi jeszcze przekształcić.

Po udanej kompilacji silnik przełącza ścieżkę wywołań funkcji na skompilowane domknięcie. Kolejne wywołania wykonują natywny kod PHP z narzutem jednego wywołania funkcji, zamiast ponosić taki narzut dla każdego węzła AST.

Przerwanie kompilacji

Szybka ścieżka skompilowanego kodu opiera się na założeniach (liczby pozostają liczbami, a właściwości pozostają w tym samym kształcie). Gdy założenie zostanie naruszone w czasie wykonywania, domknięcie zgłasza Bailout i wykonywanie tego wywołania rozpoczyna się ponownie w walkerze drzewa.

Właśnie tu zgodność ze specyfikacją ma największe znaczenie. Walker drzewa musi móc ponownie wykonać wywołanie, które spowodowało przerwanie, od początku i bez efektów ubocznych po nieudanej szybkiej ścieżce. Phasis obsługuje to na dwa sposoby:

  1. Czyste prefiksy — kompilator wyszukuje efekty uboczne przed rozpoczęciem spekulacji. Jeśli funkcja wykonuje operacje I/O (console.log, funkcje hosta) przed punktem spekulacji, szybka ścieżka ich nie pomija, lecz zatwierdza je i kontynuuje.
  2. Odmowa kompilacji — w przypadku kształtów wymagających wycofania widocznych efektów ubocznych (nienumeryczne let r = fn(), gdy fn() modyfikuje stan) kompilator w ogóle odmawia kompilacji. Funkcja pozostaje obsługiwana przez walker drzewa.

Przykład, który motywuje to rozwiązanie: przypisanie wartości nienumerycznej wykonuje fn() na szybkiej ścieżce, zgłasza Bailout, a następnie jest wykonywane ponownie przez walker drzewa — przez co fn() uruchamia się dwa razy i powoduje dwa zestawy efektów ubocznych. Odmowa kompilacji zapobiega uruchomieniu fn() na szybkiej ścieżce, więc walker drzewa obsługuje tylko jedno wywołanie.

Mechanizm dispatch maszyny wirtualnej (druga warstwa)

Niektóre funkcje są kompilowane do domknięć PHP, a pozostałe trafiają do jeszcze prostszego interpretera bajtkodu w src/Bytecode/VM.php. Bajtkod to płaska tablica kodów operacji (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …), przetwarzana przez mechanizm dispatch switch.

Maszyna wirtualna obsługuje funkcje, których nie warto kompilować do domknięcia PHP, ale które są wywoływane wystarczająco często, by skorzystać z pominięcia przechodzenia przez AST. Wywołania metod wbudowanych obiektów (Array.prototype.map, String.prototype.split, …) korzystają z szybkich ścieżek maszyny wirtualnej, które omijają ogólny trampolinowy mechanizm callFunction.

Własny stos wywołań

Op::CALL, Op::CALL_METHOD i Op::NEW_CALL korzystają ze ścieżki wywołań inline, która utrzymuje cały stos wywołań JS wewnątrz VM::execute — bez tworzenia nowej ramki PHP dla każdego wywołania JS. Przy wejściu stan wywołującego (pc, strumień kodu, stos operandów, zmienne lokalne, env, this, flaga trybu ścisłego) jest odkładany w puli savedFrames instancji SavedFrame przypisanej do maszyny wirtualnej, a pętla dispatch przestawia zmienne lokalne na zmienne wywoływanego elementu. Op::RET zdejmuje zapisaną ramkę i przywraca stan. Wyjątki są rozwijane przez zapisane ramki, zanim sterowanie wróci do walkera drzewa.

Kwalifikowalność jest zapamiętywana w JsFunction: vmInlineCallCache dla zwykłych wywołań (treść skompilowana do BC, bez powiązania przez bind() i niebędąca kodem natywnym), vmInlineConstructCache dla wyrażeń new (dodatkowo isConstructable, bez klasy pochodnej oraz inicjalizatorów pól instancji ani metod prywatnych). Pozostałe przypadki nadal przechodzą przez kanoniczny mechanizm trampolinowy callFunction / vmNewExpression.

Korzyść architektoniczna: limit stosu PHP nie ogranicza już głębokości rekurencji JS. Teraz jedynym limitem jest CallStack::maxDepth — ustawiony na 4 096, czyli znacznie powyżej każdej uzasadnionej sytuacji w test262 (większość przypadków wymaga mniej niż 2 k ramek), a zarazem na tyle nisko, by testy patologicznej nieskończonej rekurencji szybko kończyły się w ramach limitu czasu runnera. Ścieżka transpilowanych domknięć (JsToPhp) ma własny limit 8 ramek 192 dla przypadków awaryjnych, które nie są obsługiwane inline.

Generatory z migawką ramki

Proste treści generatorów — bez yield*, bez yield wewnątrz try/catch/finally i bez await — są kompilowane do bajtkodu. Kompilator emituje nowy kod operacji Op::YIELD w miejscu zawieszenia treści; Op::YIELD zapisuje stan dispatch (cf, pc+1, stos, sp, zmienne lokalne, env, thisValue, strict) w obiekcie sterty GeneratorSnapshot i zwraca z VM::execute znacznik zastępczy YieldResult. Sterujący JsGenerator zapisuje migawkę i przy następnym next() / throw() przekazuje ją ponownie do VM::execute, zastępując wartość wznowienia na szczycie stosu operandów.

To rozwiązanie zastępuje ścieżkę PHP-Fiber w obsługiwanym podzbiorze. Sterownik oparty na Fiberze (executeGeneratorBody) nadal obsługuje kształty odrzucane przez kompilator — delegowanie yield*, generatory wykonujące yield wewnątrz bloku try, generatory asynchroniczne oraz funkcje asynchroniczne z await. JsGenerator działa w dwóch trybach: każde next() / throw() / return() sprawdza, czy instancja ma migawkę czy Fiber, i odpowiednio przekazuje sterowanie.

Brak parametru do strojenia w benchmarkach; kwalifikowalność jest ustalana w czasie kompilacji i zapamiętywana w JsFunction. Zysk to o jedno przełączenie kontekstu PHP mniej na każde yield w ścieżce z migawką; pozostałe przypadki nadal korzystają z niezmienionej ścieżki Fiber.

Pamięci podręczne inline

Op::LOAD_MEMBER ma pamięć podręczną inline dla każdego PC, opartą na tożsamości prototypu i używaną w przypadku wywołania metody prototypu na instancji (c.sum(...), arr.push(...) i każde wywołanie metody klasy). Warunki trafienia: odbiornik nie ma własnego pola o danej nazwie, buforowana referencja do prototypu jest identyczna z getPrototype() odbiornika, a PropertyMap::$version prototypu nadal odpowiada zapisanej wersji.

Unieważnianie pamięci podręcznej opiera się na PropertyMap::$version. Modyfikacje strukturalne (set / delete) zawsze zwiększają jego wartość. Wartość zwiększa się po nadpisaniu wartości pola danych tylko wtedy, gdy PropertyMap::$isUsedAsProto ma wartość true — jest to flaga bariery zapisu ustawiana przy pierwszym wykryciu, że obiekt stanowi [[Prototype]] innego obiektu. Instancje, które nigdy nie służą jako prototypy, nie ponoszą narzutu infrastruktury IC przy każdym obj.x = v.

Akcesory getterów nigdy nie są buforowane (muszą być wykonywane przy każdym odczycie).

PropertyMap ma drugi licznik, $mutationVersion, którego wartość zwiększa się bezwarunkowo przy każdym zapisie (bez bariery zapisu). Pamięć podręczna JSON.stringify zapisuje wartość tego licznika dla każdego odwiedzanego zagnieżdżonego obiektu lub tablicy, aby mutacja dotycząca tylko instancji, taka jak o.a = 99, prawidłowo unieważniała buforowany tekst, nawet gdy $version IC pomijałoby zwiększenie wartości licznika.

Pamięć podręczna JSON.stringify

JsObject przechowuje $jsonCacheString + $jsonCacheVersions (listę par [obj, mutationVersion] dla każdego zagnieżdżonego obiektu odwiedzonego podczas prostego przebiegu serializacji do tekstu). Przy kolejnym wywołaniu JSON.stringify(o) bez funkcji replacer i bez parametru space, prosta szybka ścieżka sprawdza, czy żadna z zapisanych wersji się nie zmieniła, i zwraca buforowany tekst bez ponownego przechodzenia po strukturze i kodowania. Często używane wzorce głębokiego klonowania, takie jak JSON.parse(JSON.stringify(o)), przyspieszają część serializacji do tekstu mniej więcej dwukrotnie.

Pamięć podręczna jest po cichu odrzucana w razie chybienia (gdy różni się dowolna zapisana wersja) i wypełniana przy następnym przebiegu. Obiekty z toJSON, getterami, akcesorami, symbolami lub polami [[PrimitiveValue]] przechodzą do serializatora zgodnego ze specyfikacją i nigdy nie zasilają pamięci podręcznej — dlatego buforowana ścieżka obsługuje wyłącznie dane o jednolitej strukturze.

Szybkie ścieżki dla wbudowanych elementów

Największe przyspieszenia przyniosło dostosowanie maszyny wirtualnej do konkretnych wbudowanych elementów:

  • Atomics.load / store / compareExchange z kooperacyjnym planowaniem loadSpinHook / storeNotifyHook — odzyskano 4 testy pętli aktywnego oczekiwania Atomics.
  • Date.prototype.getTimezoneOffset z bezpośrednią pamięcią podręczną DST opartą na hashmapie — przyspieszenie o 45 % w teście obciążeniowym DST SpiderMonkey.
  • String.fromCharCode z szybką ścieżką inline dla ASCII — oszczędza około 1 mln operacji dispatch podczas typowego przebiegu.
  • decodeURI / decodeURIComponent z ultraszybką ścieżką %XX%XX%XX%XX dla ciągów 12-znakowych, wykorzystującą zbiorcze dekodowanie hex2bin.
  • Object.keys / Object.values / Object.entries z bezpośrednim dostępem do mapy właściwości.
  • Array.prototype.map / filter / forEach / reduce z natywną iteracją po bazowej tablicy PHP.

Każda szybka ścieżka jest zabezpieczona „sprawdzeniem kształtu” — jeśli odbiornik nie odpowiada oczekiwanemu kształtowi (e.g, widok TypedArray, który nie jest oparty na zwykłym PHP string), maszyna wirtualna przechodzi do wolnej ścieżki zgodnej ze specyfikacją.

Czego to rozwiązanie nie robi

Maszyna wirtualna bajtkodu nie jest JIT-em w rozumieniu V8. Nie generuje natywnego kodu maszynowego dla każdej funkcji i nie przeprowadza spekulacji na podstawie zaobserwowanych typów, poza monomorficznym sprawdzeniem tożsamości prototypu w IC. Inlining na granicy funkcji jest przybliżany przez własny stos wywołań (bez dodatkowej ramki PHP dla każdego wywołania JS), a nie przez obniżanie treści wywoływanej funkcji do bajtkodu wywołującej.

Transpilator JsToPhp jest najbliższy temu, co w silniku przypomina JIT: generuje kod źródłowy PHP dla często wywoływanych treści funkcji, który tracing JIT z wersji 8.5 PHP kompiluje następnie do natywnego kodu. JsToPhp korzysta z eval(), więc ponosi jednorazowy koszt kompilacji, a tracing JIT musi wykryć tę funkcję tak, jakby była kodem użytkownika. Pominięcie kroku eval() przez bezpośrednie emitowanie kodów operacji opcache pozwoliłoby jeszcze bardziej zmniejszyć tę różnicę — zobacz Benchmarki § Plan rozwoju.

Benchmarki

bench/microbench.js uruchamia kilkanaście niewielkich testów wydajnościowych (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). Mediana czasu rzeczywistego:

Powłoka
php bench/run.php

Aktualne wyniki są zatwierdzane w BENCH.md po każdym uruchomieniu workflow bench. Workflow macierzy bench i test262 są uruchamiane ręcznie.