История о V8, React и падении производительности. Часть 2

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
Сегодня мы публикуем вторую часть перевода материала, посвящённого внутренним механизмам V8 и расследованию проблемы с производительностью React.



→ Первая часть

Устаревание и миграция форм объектов


Что если поле изначально содержало Smi-значение, а потом ситуация изменилось и в нём понадобилось хранить значение, для которого представление Smi не подходит? Например — как в следующем примере, когда два объекта представлены с использованием одной и той же формы объекта, в которой x изначально хранится в виде Smi:

const a = { x: 1 };
const b = { x: 2 };
// Сейчас `x` в объектах представлено в виде поля `Smi`

b.x = 0.2;
// Теперь `b.x` представлено в виде поля `Double`

y = a.x;

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


Для представления объектов используется одна и та же форма

Когда свойство b.x меняется и для его представления приходится использовать формат Double, V8 выделяет в памяти место под новую форму объекта, в которой x назначается представление Double, и которая указывает на пустую форму. V8, кроме того, создаёт сущность MutableHeapNumber, которая используется для хранения значения 0.2 свойства x. Затем мы обновляем объект b так, чтобы он ссылался бы на эту новую форму и изменяем слот в объекте так, чтобы он ссылался бы на ранее созданную сущность MutableHeapNumber по смещению 0. И наконец, мы помечаем старую форму объекта как устаревшую и отключаем её от дерева переходов. Делается это путём создания нового перехода для 'x' из пустой формы в ту, которую мы только что создали.


Последствия назначения свойству объекта нового значения

В этот момент мы не можем полностью удалить старую форму, так как она всё ещё используется объектом a. К тому же, весьма затратным будет обход всей памяти в поиске всех объектов, ссылающихся на старую форму, и немедленное обновление состояния этих объектов. Вместо этого V8 использует тут «ленивый» подход. А именно, все операции по чтению или записи свойств объекта a сначала переводятся на использование новой формы. Идея, заложенная в этом действии, заключается в том, чтобы в итоге сделать устаревшую форму объекта недостижимой. Это приведёт к тому, что с ней разберётся сборщик мусора.


Память, занимаемую устаревшей формой, освободит сборщик мусора

Сложнее обстоят дела в ситуациях, когда поле, меняющее представление, не является последним в цепочке:

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

o.y = 0.1;

В этом случае V8 необходимо найти так называемую форму разделения (split shape). Это — последняя форма в цепочке, находящаяся до формы, в которой появляется соответствующее свойство. Здесь мы меняем y, то есть — нам надо найти последнюю форму, в которой не было y. В нашем примере это — форма, в которой появляется x.


Поиск последней формы, в которой не было изменённого значения

Здесь мы, начиная с этой формы, создаём новую цепочку переходов для y, которая воспроизводит все предыдущие переходы. Только теперь свойство 'y' будет представлено в виде Double. Теперь мы используем эту новую цепочку переходов для y, помечая как устаревшее старое поддерево. На последнем шаге мы осуществляем миграцию экземпляра объекта o на новую форму, используя теперь для хранения значения y сущность MutableHeapNumber. При таком подходе новый объект не будет использовать фрагменты старого дерева переходов и, после того, как все ссылки на старую форму исчезнут, исчезнет и устаревшая часть дерева.

Расширяемость и целостность переходов


Команда Object.preventExtensions() позволяет полностью запретить добавление в объект новых свойств. Если обработать объект этой командой и попытаться добавить в него новое свойство — будет выдано исключение. (Правда, если код выполняется не в строгом режиме, то исключение выдано не будет, однако попытка добавления свойства просто не приведёт ни к каким последствиям). Вот пример:

const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Cannot add property y;
//            object is not extensible

Метод Object.seal() действует на объекты так же, как и Object.preventExtensions(), но он, кроме того, помечает все свойства как не поддающиеся настройке. Это означает, что их нельзя удалить, нельзя и изменить их свойства, касающиеся возможностей их перечисления, настройки или перезаписи.

const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Cannot add property y;
//            object is not extensible
delete object.x;
// TypeError: Cannot delete property x

Метод Object.freeze() выполняет те же действия, что и Object.seal(), но его использование, кроме того, ведёт к тому, что значения существующих свойств нельзя менять. Они помечаются как свойства, в которые нельзя записывать новые значения.

const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Cannot add property y;
//            object is not extensible
delete object.x;
// TypeError: Cannot delete property x
object.x = 3;
// TypeError: Cannot assign to read-only property x

Рассмотрим конкретный пример. У нас имеются два объекта, каждый из которых имеет единственное значение x. Затем мы запрещаем расширение второго объекта:

const a = { x: 1 };
const b = { x: 2 };

Object.preventExtensions(b);

Обработка этого кода начинается с действий, которые нам уже известны. А именно, производится переход от пустой формы объекта к новой форме, которая содержит свойство 'x' (представленное в виде сущности Smi). Когда мы запрещаем расширение объекта b — это приводит к выполнению особого перехода к новой форме, которая отмечена как нерасширяемая. Этот особый переход не приводит к появлению некоего нового свойства. Это, на самом деле, просто маркер.


Результат обработки объекта с помощью метода Object.preventExtensions()

Обратите внимание на то, что мы не можем просто поменять существующую форму с имеющимся в ней значением x, так как она нужна другому объекту, а именно — объекту a, который всё ещё поддаётся расширению.

Проблема с производительностью React


Теперь давайте соберём всё то, о чём мы говорили, и воспользуемся полученными знаниями для понимания сущности недавней проблемы с производительностью React. Когда команда React профилировала реальные приложения, она заметила странную деградацию производительности V8, которая действовала на ядро React. Вот упрощённое воспроизведение проблемного участка кода:

const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;

У нас имеется объект с двумя полями, представленными в виде сущностей Smi. Мы предотвращаем дальнейшее расширение объекта, после чего выполняем действие, которое приводит к тому, что второе поле приходится представлять в формате Double.

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


Последствия запрета расширения объекта

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

Теперь нам нужно изменить способ представления свойства y на Double. Это означает, что нам требуется приступить к поиску формы разделения. В данном случае это форма, в которой появляется свойство x. Но теперь V8 оказывается в замешательстве. Дело в том, что форма разделения была расширяемой, а текущая форма была помечена как нерасширяемая. V8 не знает о том, как в подобной ситуации воспроизвести процесс переходов. В результате движок попросту отказывается от попыток во всём этом разобраться. Вместо этого он просто создаёт отдельную форму, которая не связана с текущим деревом формы и не используется совместно с другими объектами. Это — нечто вроде «осиротевшей» формы объекта.


«Осиротевшая» форма

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

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

class FiberNode {
  constructor() {
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();

Эти поля (например — actualStartTime) инициализировались значениями 0 или -1. Это приводило к тому, что для внутреннего представления их значений использовались сущности Smi. Но позже в них сохранялись реальные отметки времени в формате чисел с плавающей точкой, возвращаемые методом performance.now(). Это приводило к тому, что эти значения уже нельзя было представить в виде Smi. Для представления этих полей теперь требовались сущности Double. Вдобавок ко всему этому в React ещё и предотвращалось расширение экземпляров класса FiberNode.

Изначально наш упрощённый пример можно было бы представить в следующем виде.


Начальное состояние системы

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


V8 оказывается в замешательстве

V8 назначает новую «осиротевшую» форму объекту node1. То же самое немного позже происходит и с объектом node2. В результате у нас теперь имеются две «осиротевшие» формы, каждая из которых используется только одним объектом. Во множестве реальных React-приложений количество подобных объектов куда больше, чем два. Это могут быть десятки или даже тысячи объектов класса FiberNode. Несложно понять, что подобная ситуация не особенно хорошо сказывается на производительности V8.

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


Начальное состояние системы

Вот как это выглядит. Два экземпляра класса FiberNode ссылаются на нерасширяемую форму. При этом 'actualStartTime' представлено в виде Smi-поля. Когда выполняется первая операция присваивания значения свойству node1.actualStartTime — создаётся новая цепочка переходов, а предыдущая цепочка помечается как устаревшая.


Результаты присвоения нового значения свойству node1.actualStartTime

Обратите внимание на то, что в новой цепочке теперь правильно воспроизводится переход к нерасширяемой форме. Вот в какое состояние попадает система после изменения значения node2.actualStartTime.


Результаты присвоения нового значения свойству node2.actualStartTime

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

Обратите внимание на то, что операции по пометке форм объектов в виде устаревших и их миграция может выглядеть как нечто сложное. На самом деле — так оно и есть. Мы подозреваем, что на реальных веб-сайтах это приносит больше вреда (в плане производительности, использования памяти, сложности), чем пользы. Особенно — после того, как, в случае со сжатием указателей, мы больше не можем использовать этот подход для хранения Double-полей в виде значений, встроенных в объекты. В результате мы надеемся полностью отказаться от механизма устаревания форм объектов V8 и сделать сам этот механизм устаревшим.

Надо отметить, что команда React решила рассматриваемую проблему своими силами, сделав так, чтобы поля в объектах класса FiberNodes изначально были бы представлены значениями Double:

class FiberNode {
  constructor() {
    // Принуждаем систему использовать представление `Double` с самого начала.

    this.actualStartTime = Number.NaN;
    // После этого можно инициализировать значение свойства так, как нужно:
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();

Здесь вместо Number.NaN может быть использовано любое значение с плавающей точкой, не укладывающееся в диапазон Smi. Среди таких значений — 0.000001, Number.MIN_VALUE, -0 и Infinity.

Стоит отметить то, что описываемая проблема в React была специфичной для V8, и то, что, создавая некий код, разработчикам не нужно стремиться к оптимизации его в расчёте на конкретную версию некоего JavaScript-движка. Однако полезно иметь возможность что-то исправить, оптимизируя код, в том случае, если причины неких ошибок коренятся в особенностях движка.

Стоит помнить о том, что в недрах JS-движков происходит много всяких удивительных вещей. JS-разработчик может помочь всем этим механизмам, по возможности не присваивая одним и тем же переменным значения разных типов. Например, не стоит инициализировать числовые поля значением null, так как это сведёт на нет все преимущества от наблюдения за представлением поля и улучшит читаемость кода:

// Не делайте этого!
class Point {
  x = null;
  y = null;
}

const p = new Point();
p.x = 0.1;
p.y = 402;

Другими словами — пишите читабельный код, а производительность придёт сама!

Итоги


В этом материале мы рассмотрели следующие важные вопросы:

  • JavaScript различает «примитивные» и «объектные» значения, а результатам typeof нельзя доверять.
  • Даже значения, имеющие один и тот же JavaScript-тип, могут быть представлены разными способами в недрах движка.
  • V8 пытается найти оптимальный способ представления для каждого свойства объекта, используемого в JS-программах.
  • В определённых ситуациях V8 выполняет операции по пометке форм объектов в виде устаревших и выполняет миграцию форм. В том числе — реализует переходы, связанные с запретом расширения объектов.

Основываясь на вышесказанном, мы можем дать некоторые практические советы по JavaScript-программированию, которые могут помочь в деле повышения производительности кода:

  • Всегда инициализируйте свои объекты одним и тем же способом. Это способствует эффективной работе с формами объектов.
  • Ответственно подходите к выбору начальных значений для полей объектов. Это поможет JavaScript-движкам в выборе способа внутреннего представления этих значений.

Уважаемые читатели! Оптимизировали ли вы когда-нибудь свой код в расчёте на внутренние особенности неких JavaScript-движков?

Источник: https://habr.com/ru/company/ruvds/blog/467249/


Интересные статьи

Интересные статьи

Двумерное indie… трехмерные AAA-проекты… А может что-то промежуточное? Приветствую всех читателей. Мною было решено обобщить и перевести замечательную серию статей «Ray-Casting Tut...
Что такое BMS Система мониторинга работы инженерных систем в ЦОДе – ключевой элемент инфраструктуры, напрямую влияющий на такой важный показатель для дата-центра, как скорость реакции персон...
Древние люди, надев звериные шкуры, заложили основы экспансии вида homo sapiens. Вместо того, чтобы тратить миллионы лет на эволюционные изменения организма, человек начал использовать снаряжение...
Всем привет! Меня зовут Максим Рындин, я тимлид двух команд в Gett – Billing и Infrastructure. Хочу рассказать про продуктовую веб-разработку, которую мы в Gett ведем преимущественно на языке...
Введение Данная публикация направлена на изучение некоторых приемов реверс-инжиниринга. Все материалы представлены исключительно в ознакомительных целях и не предназначены в использовании в чьих...