Давайте посмотрим, как работает Javascript под капотом и возможные оптимизации производительности.

Сначала краткое резюме. Не существует единого движка Javascript, их много, и я расскажу о некоторых из них, наиболее популярных и используемых, расскажу об их различиях и подсказках по повышению производительности для оптимизации вашего приложения. Также многие из этих движков сегодня могут выполнять WASM. Для этих целей в движках есть разные компиляторы, но я не буду вдаваться в подробности.

Помните, что некоторые движки могут использовать разные слова для обозначения одних и тех же вещей. Я также постараюсь упомянуть об этом, когда это будет необходимо.

Javascript-движки

Давайте сначала поговорим о движках в целом, что они собой представляют: в основном это программы, которые выполняют наш код Javascript, потому что наш компьютер ничего не знает о Javascript. Он понимает только машинный код, так что это то, что делают движки. Преобразование нашего кода Javascript в машинный код.

Вопрос, является ли Javascript интерпретируемым или компилируемым языком? Ну, это зависит. Несмотря на то, что он классифицируется как интерпретируемый язык, так ли это на самом деле? Так как именно работают двигатели? Все они работают по-разному, но и одинаково. Ладно, хватит загадок, давайте углубимся.

Действительно ли Javascript интерпретируется?

Это зависит от Javascript Engine, который фактически выполняет код. Некоторые движки, такие как V8, SpiderMonkey, JavascriptCore и т. д., реализуют компилятор JIT, известный как компилятор Just in Time. Я не буду вдаваться в подробности о JIT, но в основном это лучшее из двух миров, таких как Interpreted и Compiled.

Также есть другие, которые интерпретируют во время выполнения, такие как Rhino (он работает в обоих направлениях, компилируется и интерпретируется), JerryScript, Boa и т. д.

Так что ответ на эти вопросы ни да, ни нет :) Я пройдусь по некоторым из вышеперечисленных движков, но помните, что некоторые из них имеют разные цели. Список можно посмотреть в здесь.

Одинаковые части двигателей

В конце концов, у всех движков много общего, все они принимают стандарты ECMAScript.Как вы знаете, целью ECMAScript является обеспечение сохраняйтесовместимость между различными браузерами (или на многих современных платформах). Что произойдет, если все движки будут иметь разные реализации для компиляции Javascript? Да ничего хорошего :)

Например, каждый объект Javascript имеет некоторые атрибуты свойств. Вы уже использовали их раньше, не зная, что они существуют в каждом свойстве объекта.

Это стандартизация атрибутов свойств объекта. Я вернусь к этим атрибутам свойства позже, поэтому я привожу это в качестве примера.

Движки отвечают за соблюдение стандартов ECMAScript. Например, если вы выбираете V8, у них даже есть несколько хороших комментариев, объясняющих, какая строка соответствует какому стандарту. Если вы выполните поиск https://tc39.es/ecma262/#sec-validateandapplypropertydescriptor в кодовой базе, вы увидите, какие строки предназначены для этой стандартизации.

Рабочий поток двигателей

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

На приведенной выше диаграмме мы видим, что сначала анализируется и анализируется наш код Javascript. Вот где происходит лексический анализ и токенизация. Токенизация в основном разделяет наш код на части. Ключевые слова, идентификаторы, знаки препинания, операторы и т. д. См. несколько примеров из исходного кода V8. M(name, string, precedence)

  • K представляет keyword токена
  • E представляет binary operator токенов
  • T представляет non-keyword токенов

Именно из этих токенов создается AST (Абстрактное синтаксическое дерево). Так как же выглядит AST? См. представление AST в формате JSON для объявления базовой переменной:

const a = 5;

Если хотите попробовать, зайдите на astexplorer.net и убедитесь сами. Это хороший онлайн-инструмент, показывающий вам AST для кода Javascript. Этот сайт не связан ни с одним движком. Я имею в виду, что это то, что в основном создают движки, но слова могут немного отличаться и т. Д., Но идея та же.

После этого AST отправляется на Interpreter. Интерпретатор в основном интерпретирует AST, генерирует байт-код (разные движки могут иметь разные компиляторы байт-кода и синтаксис), собирает отзывы и отправляет эти отзывы в Optimization Compiler.

Созданный байт-код и оптимизированный машинный код можно легко увидеть в Node с помощью флагов --print-bytecode и --print-opt-code.

Ваш код Javascript работает быстро? Если да, то вы можете поблагодарить оптимизационных компиляторов за это и, конечно же, за ваш хороший, чистый и производительный код :) Оптимизационный компилятор может делать некоторые предположения, основанные на предыдущей информации, но не всегда, когда эти предположения верны. В таких ситуациях код необходимо деоптимизировать, и мы можем кое-что с этим сделать. Мы увидим подробности об этом, но сначала давайте поговорим о нескольких популярных двигателях.

Двигатель V8

Вероятно, самый популярный на сегодняшний день движок, разработанный Chromium Project и написанный на C++. Он поддерживает Google Chrome, веб-браузеры Chromium, среды выполнения Node и Deno и т. д.

Я уже дал вам несколько образцов из V8. В чем отличия от приведенного выше потока в V8? Поток почти такой же. Интерпретатор V8 с именем Ignition и компилятор оптимизации с именем TurboFan

Движок JavascriptCore (АО)

JavascriptCore — это форк движка KDE JS, но с тех пор он прошел долгий путь. Написан в основном на C++, а также на Objective-C, Ruby и т. д. Он также известен как SquirrelFish и SquirrelFish Extreme. Также в контексте браузера Safari он известен как Nitro и Nitro Extreme. Он поддерживает Safari и многие другие приложения в экосистеме Apple, Bun Runtime, React Native (я не буду их описывать, но это также имеет некоторые детали в зависимости от мобильной ОС. Вы также можете использовать Hermes Engine, который разработан Meta для React). Родной) и др.

Что с потоком? Ну, это немного по-другому в АО. Я имею в виду, что общая логика одинакова, но у него более одного JIT-компилятора, у них нет единого компилятора оптимизации, как у V8.

АО имеет интерпретатор и 3 разных JIT-компилятора для оптимизации. Хорошо, что это:

  • LLInt означает интерпретатор низкого уровня. Он выполняет байт-код (уровень профилирования)
  • Baseline JIT это шаблон JIT, и результат очень похож на LLInt (уровень профилирования)
  • DFG — оптимизирующий JIT с малой задержкой, который создает менее оптимизированный код (оптимизирующий уровень).
  • FTL — это высокопроизводительный JIT-оптимизатор, который создает полностью оптимизированный код (оптимизирующий уровень).

Каждый уровень выполняет оптимизацию на основе собранной информации с нижних уровней. Все они работают одновременно? Не совсем, это зависит. В АО есть процесс под названием tiering-up. Это повышение уровня выполняется с помощью OSR (замена при стекировании).

Замена стека — это метод переключения между различными реализациями одной и той же функции. В этом случае разные реализации на каждом уровне. Также, как я уже говорил, разные движки имеют разные слова для одних и тех же вещей, деоптимизация известна как OSR-Exit в АО.

Для каждого уровня существуют разные пороговые значения (помните, что все эти цифры приблизительны):

Базовый JIT срабатывает для функций, которые вызываются не менее 6 раз или выполняют цикл не менее 100 раз (или некоторую комбинацию — например, 3 вызова с общим количеством итераций цикла 50).

DFG JIT срабатывает для функций, которые вызываются не менее 60 раз или которые зацикливались не менее 1000 раз.

FTL JIT срабатывает для функций, которые вызываются тысячи раз или повторяются десятки тысяч раз.

ПаукОбезьяна Двигатель

Первый Javascript-движок. Создан Бренданом Эйхом (также создателем Javascript) в 1995 году. Сегодня он поддерживается Mozilla Foundation. В основном он написан на C++ и Rust. На нем работают Firefox, MongoDB Shell и другие. Также существует Node Runtime, использующий SpiderMonkey в качестве движка, который называется SpiderNode.

Логика SpiderMonkey снова очень похожа на V8 и JSC. Как и в АО, в SpiderMonkey есть более одного JIT-компилятора. Baseline Compiler и WarpMonkey.

Компиляторы оптимизации

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

Имейте в виду, что каждый движок может иметь разные методы оптимизации, но в целом они в основном похожи, поэтому я буду рассматривать их в целом и не буду ограничиваться одним движком. Также помните, что многие компиляторы других языков выполняют аналогичную оптимизацию.

Встраивание

Встраивание — это процесс замены вызова подпрограммы или функции в месте вызова телом вызываемой подпрограммы или функции.

Давайте рассмотрим пример, см. приведенную ниже функцию:

const add = (a, b) => {
    return a + b;
};
for (let i = 0; i < 100000; i++) {
    add(i, i * 2);
}

Какие оптимизации можно сделать для этого? Когда вы звоните add, у него есть некоторые недостатки. Вызов функций обходится дороже, чем непосредственное написание тел функций (не всегда так), потому что есть накладные расходы на вызов, почему? Потому что, когда вы вызываете функцию, под одной из них происходит много вещей: будет создан новый фрейм стека и помещен поверх стека со всеми данными функции.

Перемещение тела функции add в тело вызывающей функции (в данном случае для цикла) будет выглядеть примерно так:

for (let i = 0; i < 100000; i++) {
    i + i * 2;
}

Цикл-инвариантное кодовое движение (LICM)

Также известно как "Подъем" (не путайте с "подъемом" в Javascript, он чем-то похож, но это не так) или "Скалярное продвижение".

В основном это повторяет одно и то же снова и снова в цикле для операторов, результат которых никогда не меняется, поэтому компилятор просто перемещает его из цикла. Давайте посмотрим на пример:

let sum = 0;
for (let i = 0; i < 1000; i++) {
    sum += 10 + 10 * 2;
}

Проблема здесь в том, что мы вычисляем 10 + 10 * 2 тысячу раз. Мы можем вывести вычисление из цикла и использовать результат в цикле.

let sum = 0;
let result = 10 + 10 * 2;
for (let i = 0; i < 1000; i++) {
    sum += result;
}

Устранение общих подвыражений (CSE)

Думайте об этом как DRY, не повторяйтесь. Давайте посмотрим на пример напрямую:

const a = 5;
const b = 10;
const c = (a * b) / 2;
const d = (a * b) + (a * b);

Ты это видел? Мы вычисляем a * b три раза. Мы можем отказаться от выполнения одних и тех же вычислений, переместив результат вычисления в переменную.

const a = 5;
const b = 10;
const tmp1 = a * b;
const c = tmp1 / 2;
const d = tmp1 + tmp1;

Устранение мертвого кода (DCE)

Также известно как встряхивание деревьев

Вы помните, что иногда ваша среда разработки и редактор предупреждают вас о коде, который никогда не используется, о функции, которая никогда не вызывается, или о коде, который недоступен? Да, компилятор просто выбросит его. (даже некоторые упаковщики делают это, например Babel, Webpack, Esbuild и т. д.)

Снижение силы

Речь идет о замене дорогостоящих операций эквивалентными, но более дешевыми.

let x = 7;
let arr = [];
for (let i = 0; i < 10; i++) {
    arr[i] = x * i;
}

Приведенный выше код в основном вычисляет числа, которые умножаются на 7 в диапазоне от 0 до 10. Как мы можем заменить этот код более дешевой версией?

let x = 7;
let s = 0;
let arr = [];
for (let i = 0; i < 10; i++) {
    arr[i] = s;
    s += x;
}

Как это быстрее? На уровне ЦП сложение выполняется быстрее, чем умножение, как видите, мы убрали умножение. Я не уверен, что эта разница действительна для всех типов ЦП, но в противном случае вы все равно можете уменьшить мощность.
Также, например, ваш код может выполнять 5 операций для ее вычисления, а с уменьшением мощности вы можете сделать это до 4 .

Встроенные массивы

Знаете ли вы, как реализованы встроенные функции массива, которые мы используем, такие как .map(), .some() и т. д.? Давайте посмотрим на спецификацию ECMAScript для .some():

В конце эта реализация спецификации для вызова функции ниже:

[true, true, false].some(a => a === true);

будет выглядеть примерно так (в спецификации взять this как O):

const someMethod = () => {
    let callbackfn = x => x === true;
    let len = this.length;
    if (typeof callbackfn !== "function") {
        throw new TypeError();
    }
    let k = 0;
    
    while (k < len) {
        if (k in this) {
            if (callbackfn(this[k])) {
                return true;
            }
        }
        k = k + 1;
    }
    return false;
}

Движки могут оптимизировать большую часть этого кода, например, один из них удаляет if (typeof callbackfn !== "function"), это избыточно, компилятор уже знает, что callbackfn является вызываемым.
Во-вторых, мы можем удалить if (k in arr), потому что мы уже зациклились на его размере len, поэтому невозможно, чтобы это выражение вернуло false. Сделанный? Пока нет :)
Как мы знаем, мы также можем встроить callbackfn. В конце код будет выглядеть так:

const someMethod = () => {
    let len = this.length;
    let k = 0;
    
    while (k < len) {
        if (this[k] === true) {
            return true;
        }
        k = k + 1;
    }
    return false;
}

Существует гораздо больше оптимизаций, которые делают компиляторы, но я не буду описывать их все.

Несмотря на то, что компиляторы уже делают некоторые из этих оптимизаций, старайтесь не писать подобный код, если это не влияет на ваши методы и стиль. Я имею в виду, конечно, не встраивайте свои функции самостоятельно, потому что это затрудняет чтение вашего кода, но перемещайте переменные из цикла, которые не меняются в цикле. То же самое для некоторых других, таких как CSE, не повторяйте свои расчеты снова и снова.

Оптимизация производительности, которую мы можем сделать

До сих пор движки делают все, выполняя наш код, оптимизируя наши функции, но разве мы не можем сделать что-нибудь, чтобы помочь движку? Да, мы можем помочь двигателю и увеличить производительность. Я расскажу о нескольких вещах, которые мы можем сделать, чтобы помочь.

Горячие функции

Мы говорили о спецификациях ECMAScript. Возьмем в качестве примера ApplyStringOrNumericBinaryOperator, который вызывает оператор what +.

Как видите, у нас есть некоторые проверки, если это строка, если это число, другие вызовы и т. д. Предположим, что у нас есть функция, показанная ниже.

const add = (a, b) => {
    return a + b;
};
for (let i = 0; i < 100000; i++) {
    add(i, i * 2);
}
add("Doğukan", "Akkaya");

Мы вызываем функцию add сто тысяч раз. Как вы знаете, мы можем использовать оператор + как со строками, так и с числами. В цикле мы передаем число для обоих аргументов. После многократного вызова функций с числовым типом аргументов механизмы делают предположения, запоминают переданные типы, чтобы исключить проверки, требуемые спецификацией ECMAScript. add становится мономорфным в цикле for. Теперь, после этого, мы вызываем его с аргументами другого типа, и он становится полиморфным. Мы можем отслеживать оптимизации и деоптимизации с помощью Node:

Как видите, я запускаю функцию add сто тысяч раз, все вызовы с числовыми аргументами. Что произойдет, если мы сейчас вызовем его с аргументом string?

Как видите, код сначала снова оптимизируется, но после цикла функция вызывается с разными типами, поэтому она деоптимизирована.

Формы

Это опять же то, что большинство двигателей называют по-разному. АО называет их Structures, SpiderMonkey называет их Shapes, V8 называет их Maps и т. д. Их также очень часто называют Hidden Classes.

Вы помните, я сказал, что вернусь к спецификации ECMAScript для атрибутов свойств позже, сейчас самое время.

Что означает эта спецификация, каждое свойство объекта должно где-то иметь эти атрибуты. Позвольте мне представить это со ссылкой на этот код:

const object = { x: 1 };

Хорошо, но что, если в объекте есть еще одно свойство?

const object = { x: 1, y: 2 };

Для каждого свойства объекта создаются атрибуты свойств, но это не очень хорошо, верно? Потому что у нас есть много объектов и множество свойств этих объектов в наших программах. Если мы сделаем это так, будет огромная трата памяти (формы также имеют некоторые преимущества в производительности по сравнению с использованием словаря на основе хэшей). Здесь в игру вступают формы. Допустим, у нас есть тысячи объектов с одинаковыми свойствами.

const object1 = { x: 1, y: 2 };
const object2 = { x: 2, y: 3 };
// ...
const object1000 = { x: 1000, y: 10001 };

Движки не создают тысячи атрибутов свойств, вместо этого они используют формы.

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

Что происходит, когда мы добавляем новые свойства к объектам?

const object = { x: 1, y: 2 };
object.z = 3;

Конечно, форма не меняется, потому что могут быть другие объекты, которые уже используют эту форму, но мы все же можем использовать ее повторно, как?

Сначала движок создает другую форму для новых свойств, и эта новая форма также содержит свойство z. Вы можете спросить, почему вторая фигура не содержит только z, потому что z не является фигурой сама по себе, также может быть объект, в котором есть только свойство z, и смещение которого может быть 0.

Также помните, что порядок свойств важен, потому что мы сохраняем значение смещения.

const object1 = { x: 1 };
const object2 = { x: 2 };
object1.y = 2;
object2.z = 3;
object1.z = 4;
object2.y = 5;

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

const object1 = { x: 1 };
const object2 = { x: 2 };
object1.y = 2;
object2.y = 3;
object1.z = 4;
object2.z = 5;

Вы можете просмотреть их в Node, в V8 есть кое-что интересное, чтобы помочь нам.

Не волнуйтесь, если ваша IDE и редактор жалуются на синтаксис, это не что-то конкретное для Javascript, а для V8. Список таких функций можно посмотреть здесь.

Что произойдет, если вы создадите пустой объект, а затем добавите к нему свойства?

const object = {};
object.x = 1;

Для пустого объекта создается новая форма, а затем для свойства x создается другая форма.

Помните, хотя большинство движков реализовали Hidden Classes с одинаковой логикой, но их модели могут отличаться друг от друга.

Встроенное кэширование (IC)

Каждый раз, когда вы пишете object.x, движку нужно выполнить какую-то работу. Он выполнит некоторые поиски, перейдет к форме, найдет x, найдет атрибуты его свойств, найдет смещение, наконец, перейдет к значениям объекта и найдет правильное смещение. Двигатели могут сделать это более производительным.

const getName = (person) => {
  return person.name;
};
getName({ name: "Doğukan" });
getName({ name: "Doğukan" });
// ...

В приведенном выше коде мы вызываем функцию getName с объектом несколько раз (поэтому после некоторых вызовов она становится горячей). Двигатели выполнят работу, о которой я упоминал, а затем вернут person.name, но после оптимизации нам действительно нужно делать то же самое снова? Нет, двигатели помнят об этом, как мы говорим в разделе «Горячие функции». По сути, это говорит: «Вы вызывали эту функцию с теми же параметрами раньше, и я точно знаю, где находится свойство name». Думайте, что IC — это ярлыки для свойств.

Помните, что если вы передаете функции множество объектов различной формы, некоторые движки даже не будут пытаться ее оптимизировать и всегда будут идти по медленному пути.

Советы по производительности:

  • Не создавайте функции, которые принимают множество необязательных свойств. Старайтесь быть последовательными в формах объектов и типах аргументов.
  • Всегда пытайтесь установить свои свойства при инициализации объекта.
  • Если вы не можете установить свои свойства при инициализации объекта, добавьте свои свойства в том же порядке.
  • Сделайте ваши функции максимально мономорфными. Всегда старайтесь думать так, как будто вы пишете язык со статической типизацией (может помочь использование Typescript).

Ресурсы

Много видео с конференций и множество сообщений в блогах, но некоторые из имен, которыми я воспользовался, — это Франциска Хинкельманн, Михаэль Сабофф, Бенедикт Мёрер и т. д. — 👏

👉 Спасибо за прочтение 🙏 Надеюсь, вам понравилось 🙌