Преглед на двигателя V8 на Google и какво го прави бърз

Javascript двигател е програма, която приема вашия Js код като вход и генерира машинно изпълним код или както би казал някой байт код. Всъщност човек може да създаде Js двигател. За това те ще трябва да се придържат към стандарта на Ecmascript и вероятно да изберат да внедрят WebAssembly и Just in Time компилация, за да останат в тази надпревара на Js двигатели. С толкова много налични алтернативи, това всъщност е много добро за потребителя, тъй като разработчиците на Js двигатели продължават да се опитват да направят своя двигател по-ефективен, като следствие, изживяването на вашия браузър ще продължи да се подобрява. Повечето JS двигатели имат подобен поток, което се различава е вътрешната им оптимизация.

Има много JS двигатели – нещо толкова старо като spidermonkey (което е почти като първия Js двигател), някои други като Chakra, JavascriptCore, които се използват съответно в Edge и Safari, някои със съмнително бъдеще като JScript, новите като Hermes, разработени от Facebook за React Native. Толкова много Js двигатели и въпреки това V8 остава най-широко използваният Js двигател. Някои го смятат и за най-производителния двигател.

Вярно ли е това? Дали V8 е най-добрият наличен двигател днес?

Трябва да разберем едно много важно нещо за Js двигателите — Js двигателите са просто програма, написана на някакъв език, която трябва да изпълни определена задача възможно най-ефективно. Сега тази ефективност може да бъде оценена или по отношение на скоростта, или по отношение на използването на паметта. Така че, ако правите свой собствен браузър за уеб, това означава, че имате памет в изобилие и да, V8 би бил чудесен избор. Но кажете, че правите малко носимо IOT устройство и ще имате много малко налична памет. Може да искате да използвате Duktape или Jerryscript, които може да са по-бавни, но биха паснали по-добре на паметта.

И така, с какво е различен V8?

За много дълго време това, което отличаваше V8, беше, че не произвеждаше междинен код или байт код. Сега нещата са се променили. Архитектурата на V8 е променена от full-codegen — реализация на колянов вал до архитектура на запалване — турбовентилатор. Идеята е да направим всичко, което направи full-codegen-crankshaft, но и да бъдем достатъчно добри, за да се справим с непрекъснато развиващата се Js екосистема. Ще говорим за тях — интерпретатор на запалване и компилатор TurboFan, подробно в следващите раздели.

Подникване във V8, преди да се гмурнем...

V8 е написан на C++ и за да го ускори вътрешно, изпълнява следните нишки

  • Има основна нишка, която извлича, компилира и изпълнява JS код
  • Друга нишка се използва за оптимизиране, компилира, така че основната нишка продължава изпълнението, докато първата оптимизира изпълнявания код
  • Трета нишка се използва само за обратна връзка, която казва на времето за изпълнение кои методи се нуждаят от допълнителна оптимизация
  • Няколко други теми за обработка на събирането на боклука

Купчината има две сегрегации — NewSpace и OldSpace. NewSpace е мястото, където се случват най-динамичните разпределения. Някои от тези състояния са повишени в OldSpace и са необходими в цялото приложение. Тези разпределения на степа на OldSpace могат да се видят в генерирания байт код.

0x17b58eeb5549: [FixedArray] in OldSpace
 - map: 0x17b5d9c40729 <Map>
 - length: 4
 0: 0x17b58eeb4909 <String[#9]: firstname>
 1: 0x17b58eeb4941 <String[#8]: lastname>

Събирането на отпадъци се извършва по два начина — събирането на отпадъци е бързо и се изпълнява в NewSpace поради динамичния му характер, докато методът на алгоритъма Mark-Sweep е по-бавен и работи на OldSpace, където се очаква разпределенията да продължат по-дълго.

Защо е необходим байт код?

Език, който е статично въведен или има куп предварителни правила за правене на нещата по определен начин, тогава съответният компилатор има много информация за това какъв трябва да бъде компилираният код или какъв е очакваният изход. В езици като Js, където вашите обекти, променливи - всичко е или може да бъде динамично въведено, няма толкова много предварително дефинирани правила, налични за програма, която да реши изхода. Така че естествено, когато кодът се компилира предварително (прочетете това като AOT — компилация преди време), изпълнението на машинния код е по-бързо, защото програмата знае очаквания формат.

Така че, за да използват плюсовете на компилацията, Js двигателите правят компилация точно навреме, за да оптимизират изпълнението. V8 има интерпретатор, наречен “Ignition” и оптимизиращ компилатор, наречен “Turbo-fan”.

Когато е поискана страница в google chrome

HTML анализаторът намира скриптов таг някъде във вашия код

<script src=”app.js”></script>

Това се случва обикновено, или чрез действителното мрежово повикване, или чрез някакъв вид кеширане, което сте направили, и това е, когато получава изтеглен скрипт като байтов поток от данни. Те се обработват в токени от декодер на байтов поток.

Сега тези токени могат да бъдат донякъде идентифицирани - добре е, че едно от тях е ключова дума, другото изглежда като идентификатор, това може би е оператор и така нататък. Но заедно тези токени все още нямат смисъл. След това тези токени се обработват допълнително от Js анализатора, който се основава на синтаксичните правила на Js и стандарта Ecmascript, към който са се придържали. Анализаторът създава възли (да, както в дървовидните възли) от тези токени. Тези възли се обработват допълнително в AST (абстрактно синтактично дърво).

AST е много по-организирано и подробно представяне на това, което бяхме въвели като много малък js оператор. Докато дървото се конструира, това е мястото, където се случва действителната проверка за грешка, тъй като токените поотделно могат да бъдат валидни, но като свързано Js изявление може да не са. Така че тези стъпки, обсъдени досега, са общи за всички браузъри.

Гмуркане във V8

От тук нататък кодът всъщност влиза във V8 двигателя или който и да е js двигател за този въпрос. В случай на V8 отива към Ignition, който отговаря за генерирането на байт кода въз основа на AST.

Това, което трябва да се разбере тук е, че в Запалване има набор от регистри, посочени от r1, r2… и така нататък. Има акумулатор, който приема първоначалния вход и връща изхода. Всички тези регистри са свързани с операции и стойности. Това означава, че байт кодът основно казва да се извлече някаква стойност може би от r1 и да се добави към r2. Тогава може би върху тази стойност изберете стойност от r3 и извършете някои операции от тези в r1.

var obj = {
 firstname: "Piyush",
 lastname: "Das"
}
function getName(obj) {
 console.log(obj.firstname, obj.lastname)
}
// Calling the above function with obj as param
getName(obj)

Нека вземем пример, в кодовия фрагмент по-горе, ние препращаме към обект, който има две свойства - име и фамилия.

node --print-bytecode --print-bytecode-filter=getName code.js

За да генерирате байт кода, можете да стартирате вашия js файл в терминала с флага print-bytecode. Горната команда има филтърен флаг с името на вашата функция, тъй като обикновено генерираният байт код е огромен и намирането на вашата функция би било по-досадно.

Така че нашият генериран байт код за горната функция getName ще изглежда нещо като изображението по-долу.

Горният код изглежда доста сложен за четене. Но не се различава много от вида на програмата, която пишем. Всъщност това е по-скоро набор от подробни инструкции за това къде да съхранявате определени стойности и къде да ги преместите или къде да ги извлечете.

LdaNamedProperty a0, [2], [4]
// Fetch a named property into accumulator from [reference]

Както говорихме за регистрите по-рано, тук можем да видим в кодовия фрагмент по-горе, че наименувано свойство се въвежда в акумулатор a0 и се посочва от индекс [2]

0x26f018535471: [FixedArray] in OldSpace
 - map: 0x26f008e40729 <Map>
 - length: 4
 
 0: 0x26f0b6312c39 <String[#7]: console>
 1: 0x26f0b630b369 <String[#3]: log>
 2: 0x26f018534909 <String[#9]: firstname>
 3: 0x26f018534941 <String[#8]: lastname>

Препратката [2] се извлича от списък с препратки, който е споменат в байт кода, както е показано във фрагмента по-горе, извикването му чрез индекси го прави по-бързо. В индекс 2 можете да видите първото име, което се посочва.

Star r2

Още на следващия ред след LdaNamedProperty можем да видим командна звезда и препратка към регистър. Тази инструкция тук е да преместите горната стойност в регистър r2 и така следващата операция/изпълнение да може да се осъществи в a0.

В края на всичко това има връщане, което връща сегашната стойност в акумулатора.

Имаше и втора препратка в изявлението на LdaNamedProperty. Това се използва за оптимизиране на изпълнението на нашия код.

LdaNamedProperty a0, [2], [4]

Бяхме предали обекта „obj“ като параметър към функцията getName. Така че, когато това се извика и интерпретаторът действително трябва да получи достъп до стойности от обекта, се създава шаблон за справка за обекта, наречен като скрит клас. За оптимизиране на изпълнението, тъй като може да има повтарящи се извиквания, този шаблон поддържа препратка към свойства, които могат да присъстват в обекта. Той очаква скрития клас на предадения параметър да остане същият. Сега, дори ако действителните стойности се променят за тези свойства на обекта, но тъй като препратката не се е променила и в байт кода виждаме, че операциите се извършват чрез извличане на стойности от препратки, така че същата препратка да се използва директно и изпълнението може да бъде по-бързо. Това се нарича вградено кеширане, което разчита на наблюдението, че повтарящите се извиквания на един и същи метод са склонни да се случват на същия тип обект.

\\... getName defined earlier
var obj2 = {
 firstname: "PD"
}
getName(obj)

Когато извикам getName с obj2, това е валиден js и се изпълнява без грешка, като фамилното име е недефинирано. Но вътрешно това изпълнение е различно от извършеното по-рано. Защо? Това е така, защото обектът, който сме предали, има само едно свойство и като следствие, той се третира като различен скрит клас и препратките в байт кода ще се променят и ползата от вграденото кеширане не може да бъде използвана. Така че дори когато се нуждаем от случай като този, е по-добре да имаме обекта, както е показано по-долу, за да поддържаме скритата структура на класа.

var obj2 = {
 firstname: "PD",
 lastname: undefined
}

За да обобщим втората препратка и целта на скрития клас и вграденото кеширане е да се ускори търсенето на свойства в js обекти. Вграденото кеширане също така предоставя ценна обратна връзка на TurboFan оптимизатора.

V8 има FeedbackVector, който запазва зависими от контекста функционални стойности/изпълнения на код в готов за препратка вектор, така че когато точно същата операция се извърши отново, резултатът може директно да бъде извлечен.

Сега, ако моята функция getName бъде извикана многократно, тази функция ще стане „гореща“ – добре, това всъщност е нещо. Особеното при горещите функции е, че турбовентилаторът с информацията от обратната връзка ще генерира специфичен за архитектурата оптимизиран машинен код. Така че следващия път, когато тази функция бъде извикана, тя пропуска байт кода и директно извиква машинния код. Това е мястото, където отново се появяват нашите скрити класове и вграденото кеширане. Така че, когато нашите скрити класове остават същите за нашата гореща функция, този тип вградено кеширане се нарича мономорфно и нещо, което искаме да постигнем за нашето изпълнение.

Когато скритият клас се промени и може би има 3–4 различни скрити класа за функцията getName, това се нарича полиморфно. Turbofan оптимизацията все още ще работи, въпреки че процесът ще стане малко по-дълъг, тъй като всеки път, когато се срещне нов клас, кодът се деоптимизира, създава се нова справка с наклонено кеширане, FeedbackVector се актуализира и след това TurboFan може да оптимизира машинния код. Когато нашият параметър е твърде динамичен и се променя много често, това се нарича мегаморфно състояние. Turbofan няма да може да оптимизира мегаморфните състояния за директно извикване на машинен код, тъй като проверките на скрити класове се провалят много често.

Затова е важно скритите преходи на класове да бъдат сведени до минимум. Най-накрая, след всичко това, имаме оптимизирана версия на машинно готов код, готов за изпълнение...воала!

Забележка: Бих силно препоръчал да гледате видео лекцията на JS Conf от Franziska Hinkelmann, налична тук.