В прошлый раз я говорил о V8 и как он оптимизирует ваш код JavaScript. Однако я не говорил о приемах оптимизации, которые применяются во время компиляции.

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

Скрытые классы

Более серьезная проблема здесь в том, что JavaScript - это динамический язык. Свойства ваших объектов можно добавлять или удалять на лету. Для определения местоположения свойства в памяти требуется динамический поиск. Это дорогостоящая операция.

Так что же V8 делает для сокращения времени, необходимого для доступа к свойствам JavaScript?

V8 динамически создает скрытый класс за сценой. Объясню на примере, что такое скрытый класс.

Представьте, у нас есть функция Point.

function Point(x, y) {
  this.x = x;
  this.y = y;
}

При выполнении Point создается новый объект с именем Point. V8 создает начальный скрытый класс для этого объекта, например, с именем C0. На этом этапе у объекта нет свойств, поэтому скрытый класс пуст.

Первый оператор создает новое свойство в нашем объекте - x. Он изменяет объект Point, поэтому V8 создает новый скрытый класс C1 на основе C0. Кроме того, обновляет скрытый класс C0 с переходом на C1. На этом этапе наш скрытый класс для объекта Point выглядит так:

Второй оператор создает другое свойство - y. Те же шаги, что и раньше. V8 создает новый скрытый класс C2 на основе C1 и обновляет C1 с переходом на C2. На этом шаге вот наш скрытый класс:

Из-за переходов классов скрытые классы можно повторно использовать для новых объектов. В следующий раз, когда создается новый объект Point, новые скрытые классы не создаются, вместо этого новый объект Point разделяет скрытый класс с первым Point объект.

Что происходит, когда мы создаем другой объект Point?

  • Изначально Point не имеет свойств, поэтому наш объект ссылается на C0;
  • Добавляя свойство x, V8 следует за переходом скрытого класса от C0 к C1 и записывает значение x в смещение, указанное в C1;
  • Добавляя свойство y, V8 следует за переходом скрытого класса от C1 к C2 и записывает значение y в смещение, указанное в C2;

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

Тем не менее, вы должны понимать, что изменение порядка свойств в вашем конструкторе может привести к созданию новых скрытых классов. Например, вы поменяли местами x и y.

  • Изначально Point не имеет свойств, поэтому наш объект ссылается на C0;
  • После этого, добавляя свойство y (мы меняем порядок), V8 следует за скрытым переходом класса с C0 на C1 , но… нет перехода для свойства y. У нас есть переход только от свойства x;
  • То же самое для свойства x;

Это приводит к созданию новых скрытых классов, что приводит к плохой оптимизации (исправьте меня, если я ошибаюсь).

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

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

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

Но ... это заставляет V8 вводить некоторый «охранный» код. Все работает отлично, пока вы не измените объект, V8 этого не ожидает. Вот когда должен сработать «охранный» код. Он снова выполняет полный динамический поиск.

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

function render(shape, cursor) {
  // Some logic here
  shape.render(cursor);
  // Some logic here
}

Мы не знаем, что такое shape, поэтому нам нужно найти там метод render. После завершения поиска мы можем переписать вызов как прямой вызов целевого метода.

Это работает нормально, пока вы не передадите другой экземпляр shape, что означает, что прямой вызов приведет нас к неправильному объекту. Код «Guard» проверяет эти случаи и, если что-то не так, возвращается к динамическому поиску, который возвращает правильный результат.

К счастью, V8 ​​может оптимизировать эти случаи с помощью мономорфного встроенного кэширования (MIC) и полиморфного встроенного кэширования (PIC). Если у вас небольшое количество разных форм, ничего страшного. V8 справится с этим. В противном случае он стал мегаморфным сайтом для звонков и нет смысла его оптимизировать.

Это все о IC. Еще одна оптимизация, которая пытается уменьшить количество динамических поисков (как это делают скрытые классы).

Замена в стеке (OSR)

Замена в стеке - это метод переключения между разными реализациями одной и той же функции.

Когда V8 оптимизирует ваши функции, ему нужно как-то заменить реальный код оптимизированным. Вот тогда в игру вступает OSR. Он просто берет неоптимизированный код вашей функции и заменяет его оптимизированным на месте вызова.

Кроме того, когда что-то идет не так, и оптимизированный код не может справиться с некоторыми крайними случаями, OSR может вернуться к неоптимизированному коду, и вам не нужно повторно запускать приложение.

Последние шаги

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

Давайте закончим эту статью кратким списком незначительных оптимизаций, примененных к вашему коду (они применяются компилятором Hydrogen при сборке CFG):

  • Встраивание - это оптимизация компилятора, при которой сайт вызова функции заменяется телом вызываемой функции. Этот метод устраняет накладные расходы на вызов.
  • Канонизация - устраняет ненужные операции и пытается упростить другие.
  • Устранение мертвого кода (DCE) - удаляет код, не влияющий на результаты программы.
  • Обратная связь динамического типа (DTF) - как вы помните, V8 собирает информацию обратной связи типа в ваших функциях. Эта оптимизация извлекает информацию из встроенных кешей вашего кода и оптимизирует код для обработки только этого одного типа объекта.
  • … И еще много мелких оптимизаций.

Полезные ссылки

Евгений Обрезков aka ghaiklor, адвокат разработчиков компании Оникс-Системс, Кировоград, Украина.