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 doobjecti zabezpieczają przed wynikiem niebędącym obiektem. - Pętle
for/whilez prostymi warunkami. returnz wyrażeniem, które można obliczyć.if/elsez 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:
- 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.
- Odmowa kompilacji — w przypadku kształtów wymagających wycofania widocznych efektów ubocznych (nienumeryczne
let r = fn(), gdyfn()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/compareExchangez kooperacyjnym planowaniemloadSpinHook/storeNotifyHook— odzyskano 4 testy pętli aktywnego oczekiwania Atomics.Date.prototype.getTimezoneOffsetz bezpośrednią pamięcią podręczną DST opartą na hashmapie — przyspieszenie o 45 % w teście obciążeniowym DST SpiderMonkey.String.fromCharCodez szybką ścieżką inline dla ASCII — oszczędza około 1 mln operacji dispatch podczas typowego przebiegu.decodeURI/decodeURIComponentz ultraszybką ścieżką%XX%XX%XX%XXdla ciągów 12-znakowych, wykorzystującą zbiorcze dekodowaniehex2bin.Object.keys/Object.values/Object.entriesz bezpośrednim dostępem do mapy właściwości.Array.prototype.map/filter/forEach/reducez 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:
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.