Пошаговое руководство по движку Google V8 и его быстродействию

Механизм javascript - это программа, которая принимает ваш Js-код в качестве входных данных и генерирует машинный исполняемый код, или, как можно было бы сказать, байт-код. Фактически, можно создать Js-движок. Для этого им нужно будет придерживаться стандарта Ecmascript и, вероятно, выбрать реализацию WebAssembly и компиляцию Just in Time, чтобы остаться в этой гонке Js Engine. Имея так много доступных альтернатив, это на самом деле очень хорошо для пользователя, поскольку разработчики Js-движка продолжают пытаться сделать свой движок более эффективным, и, как следствие, ваш опыт работы с браузером будет улучшаться. Большинство JS-движков имеют похожий поток, но отличается их внутренняя оптимизация.

Существует множество JS-движков - что-то такое же старое, как spidermonkey (которое почти похоже на первый Js-движок в истории), некоторые другие, такие как Chakra, JavascriptCore, которые используются в Edge и Safari соответственно, некоторые с сомнительным будущим, такие как JScript, чтобы такие же новые, как Hermes, разработанный Facebook для реагирования на родные. Так много двигателей JS, и все же V8 остается наиболее широко используемым двигателем JS. Некоторые считают его также самым производительным двигателем.

Это правда? Является ли V8 лучшим двигателем на сегодняшний день?

Нам нужно понять одну очень важную вещь о Js-движках: Js-движки - это просто программа, написанная на каком-то языке, которая должна выполнять определенную задачу с максимальной эффективностью. Теперь эта эффективность может быть измерена с точки зрения скорости или использования памяти. Так что если вы создаете свой собственный браузер для Интернета, это означает, что у вас достаточно памяти, и да, V8 будет отличным выбором. Но предположим, что вы делаете небольшое носимое устройство для Интернета вещей, и у вас будет очень мало памяти. Возможно, вы захотите использовать Duktape или Jerryscript, которые могут быть медленнее, но лучше подходят для памяти.

Так чем же V8 отличается?

В течение очень долгого времени V8 отличался тем, что не производил никакого промежуточного кода или байт-кода. Сейчас все изменилось. Архитектура V8 изменилась с полной кодогенерации - реализация коленчатого вала на Ignition - турбовентиляторную архитектуру. Идея состоит в том, чтобы делать все, что делал full-codegen-crankshaft, но при этом быть достаточно хорошим, чтобы справиться с постоянно развивающейся экосистемой Js. Мы поговорим об этом - интерпретаторе Ignition и компиляторе 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.

Здесь необходимо понимать, что в Ignition есть набор регистров, обозначенных как 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.

В конце всего этого есть return, который затем возвращает текущее значение в аккумуляторе.

Также была вторая ссылка в заявлении LdaNamedProperty. Это используется для оптимизации выполнения нашего кода.

LdaNamedProperty a0, [2], [4]

Мы передали объект «obj» в качестве параметра функции getName. Поэтому, когда он вызывается и интерпретатору действительно нужно получить доступ к значениям из объекта, для объекта создается шаблон ссылки, который называется скрытым классом. Чтобы оптимизировать выполнение, поскольку могут быть повторные вызовы, в этом шаблоне хранятся ссылки на свойства, которые могут присутствовать в объекте. Ожидается, что скрытый класс переданного параметра останется прежним. Теперь, даже если фактические значения этих свойств объекта меняются, но поскольку в ссылке нет и в байт-коде, мы видим, что операции выполняются путем выборки значений из ссылок, поэтому та же ссылка может использоваться напрямую и выполнение может быть быстрее. Это называется встроенным кэшированием, которое основывается на наблюдении, что повторные вызовы одного и того же метода, как правило, происходят с одним и тем же типом объекта.

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

Когда я вызываю getName с помощью obj2, это действительный js, который выполняется без ошибок, а lastname не определено. Но внутренне это исполнение отличается от сделанного ранее. Почему? Это связано с тем, что объект, который мы передали, имеет только одно свойство и, как следствие, он рассматривается как другой скрытый класс, и ссылки в байт-коде изменятся, и преимущества встроенного кэширования не могут быть использованы. Поэтому, даже когда нам нужен такой случай, лучше иметь объект, как показано ниже, для поддержки скрытой структуры классов.

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

Подводя итог, вторая ссылка и цель скрытого класса и встроенного кеширования - ускорить поиск свойств в объектах js. Встроенное кэширование также обеспечивает ценную обратную связь оптимизатору TurboFan.

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

Теперь, если бы мою функцию getName вызывали несколько раз, эта функция стала бы «горячей» - ну, это действительно так. Особенностью горячих функций является то, что ТРДД на основе информации обратной связи генерирует машинный код, оптимизированный для конкретной архитектуры. Поэтому в следующий раз, когда эта функция вызывается, она пропускает байт-код и напрямую вызывает машинный код. Здесь снова появляются наши скрытые классы и встроенное кеширование. Поэтому, когда наши скрытые классы остаются такими же для нашей горячей функции, этот тип встроенного кэширования называется мономорфным, и мы хотим достичь этого при выполнении.

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

Поэтому важно свести скрытые переходы между классами к минимуму. Наконец, после всего этого у нас есть оптимизированная версия машинно-готового кода, готового к запуску… вуаля!

Примечание: я очень рекомендую посмотреть видеолекцию Франциски Хинкельманн по JS Conf, доступную здесь.